Base components
Textarea
A multi-line text field with the same label, hint and error behavior as Input, which can grow with its text and eases its height so the page below does not jump.
Live preview
Loading preview
A Description field with autoGrow from 4 to 8 lines, a Feedback field with the resize handle and a 500 character limit in the hint, and an invalid state with "Enter a description of at least 20 characters." Replay types three lines into the Description field so it grows.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add textareaUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Textarea } from './textarea';
const LINES = [
'Lunia DS is a motion-first design system.',
'Every component ships for React and Vue.',
'Motion tokens keep timing the same everywhere.',
];
const TYPE_MS = 24;
const LIMIT = 500;
export default function TextareaDemo({ replay = 0 }: { replay?: number }) {
const [description, setDescription] = React.useState('');
const [feedback, setFeedback] = React.useState('');
React.useEffect(() => {
if (replay === 0) return;
setDescription('');
// Types three lines, one letter at a time, so the field grows line by line.
const full = LINES.join('\n');
const timers = [...full].map((_, i) =>
window.setTimeout(() => setDescription(full.slice(0, i + 1)), (i + 1) * TYPE_MS),
);
return () => {
for (const t of timers) window.clearTimeout(t);
};
}, [replay]);
return (
<div className="grid w-full max-w-md gap-6">
<Textarea
label="Description"
required
autoGrow
rows={4}
maxRows={8}
placeholder="Enter a description..."
helpText="People see this on your project page."
value={description}
onValueChange={setDescription}
hint="Tell people what this project is about."
/>
<Textarea
label="Feedback"
size="md"
placeholder="What can we do better?"
maxLength={LIMIT}
value={feedback}
onValueChange={setFeedback}
// A live count is not a new hint, so it is passed as an element and does not replay the hint entrance.
hint={<span>{LIMIT - feedback.length} characters left.</span>}
/>
<Textarea
label="Summary"
invalid
defaultValue="Too short."
hint="Enter a description of at least 20 characters."
/>
</div>
);
}
API
Parts: root (field), label with required mark (*) and help icon, control (native textarea), resize handle, hint text (becomes the error text when invalid).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | Controlled value. |
| defaultValue | string | "" | Value when uncontrolled. |
| size | "sm" | "md" | "sm" | Text sm or Text md, with padding to match Input. |
| rows | number | 4 | Visible lines before the text scrolls, or the starting height when autoGrow is set. |
| maxRows | number | 12 | With autoGrow: the height stops growing at this many lines and the text scrolls. |
| autoGrow | boolean | false | Grows the height with the text, from rows up to maxRows. |
| resize | "vertical" | "none" | "vertical" | Shows the resize handle. Ignored when autoGrow is set. |
| label | string | undefined | Label above the field. Without it, pass aria-label. |
| placeholder | string | undefined | Example text shown while empty. |
| hint | string | undefined | Help line under the field. When invalid, pass the error text here. |
| helpText | string | undefined | Shows the help icon in the label row (Figma 83:2087) with this text in a tooltip. |
| required | boolean | false | Native required; shows the * after the label. |
| invalid | boolean | false | Error border and a red hint (Figma Destructive). |
| disabled | boolean | false | Turns the field off. |
| name | string | undefined | Form field name. |
| Event | Description |
|---|---|
| valueChange | The value changed while typing. Payload: the new value. |
| Slot | Description |
|---|---|
| hint | Rich hint content, replaces the hint prop. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TXA1 | hover the field | control border | border-color | border-primary → border-control_hover | duration-sm (200)ease-out | Swaps at once |
| TXA2 | focus | control | border-color, box-shadow | border-primary, shadow-xs → border-brand, focus-ring-shadow-xs | duration-2xs (120)ease-out | Shows at once |
| TXA3 | invalid -> true | control border, hint | border-color, color | border-primary, text-tertiary → border-error, text-error-primary | duration-sm (200)ease-out | Swaps at once |
| TXA4 | hint text changes (for example to an error) | hint | opacity, translate y | 0, -distance-rise → 1, 0 | duration-sm (200)ease-out | Swaps at once |
| TXA5 | autoGrow: a line is added or removed | control | height | old content height → new content height (between rows and maxRows) | duration-2xs (120)ease-out | Resizes at once |
Accessibility
- A native textarea inside the same field primitive as Input: label, hint and aria-invalid are linked the same way.
- The resize handle is the native one, so it works with the pointer only; autoGrow is the keyboard-friendly choice for long text.
- Height changes never move the caret or the scroll position inside the field.
- Reduced motion: the height changes at once.