Textarea
The multi-line text field — the block sibling of Input. It shares the same field surface, calm ring-less focus, rest shadow, and control-driven state, auto-grows with its content between a min and max height, keeps a vertical resize handle, and carries an optional toolbar footer for the shared in-field actions and a live character count.
Basic
Compose Textarea.Root — the block shell that paints the field chrome and owns the size — around a Textarea.Control, the editable textarea. It top-aligns its text and grows with content up to a cap, then scrolls; a vertical resize handle stays available.
"use client";
import { Textarea } from "@stridge/noctis";
export default function TextareaBasic() {
return (
<Textarea.Root className="w-full max-w-md">
<Textarea.Control aria-label="Comment" placeholder="Leave a comment…" />
</Textarea.Root>
);
}
With a field
Wrap the textarea in a Noctis Field.Root with a Field.Label, Field.Description, and Field.Error — the same framing Input uses. The label auto-associates, the description and error join the control through aria-describedby, and aria-invalid flips from validation, all without setting a state prop on the shell.
Shown on your public profile.
"use client";
import { Field, Textarea } from "@stridge/noctis";
export default function TextareaWithField() {
return (
<Field.Root className="w-full max-w-md">
<Field.Label>Bio</Field.Label>
<Textarea.Root>
<Textarea.Control required placeholder="A sentence or two about yourself…" />
</Textarea.Root>
<Field.Description>Shown on your public profile.</Field.Description>
<Field.Error match="valueMissing">Please add a short bio.</Field.Error>
</Field.Root>
);
}
Sizes
Two sizes — medium (the default) and large — sharing one field surface and type rhythm, riding the density knob. The size lives on Textarea.Root; the control and toolbar inherit its metrics.
"use client";
import { Textarea } from "@stridge/noctis";
export default function TextareaSizes() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
<Textarea.Root size="md">
<Textarea.Control aria-label="Medium" placeholder="Medium" />
</Textarea.Root>
<Textarea.Root size="lg">
<Textarea.Control aria-label="Large" placeholder="Large" />
</Textarea.Root>
</div>
);
}
Composer
Textarea.Toolbar is a footer row, divided from the text by a hairline, for the shared Textarea.Actions and a Textarea.Count — the comment-composer pattern. The action and count are the exact same field affordances Input exposes (Input.Action/Input.Count), so the footer reads consistently across the family.
"use client";
import { Icon, Textarea } from "@stridge/noctis";
import { Paperclip, SendHorizontal } from "lucide-react";
import { useId, useState } from "react";
const MAX = 280;
export default function TextareaComposer() {
const [value, setValue] = useState("");
const countId = useId();
// The composer footer: a count on the left, attach + send actions on the right — the shared field
// affordances, identical to Input's, sized to the textarea.
return (
<Textarea.Root className="w-full max-w-md">
<Textarea.Control
aria-label="Comment"
aria-describedby={countId}
placeholder="Leave a comment…"
value={value}
onValueChange={setValue}
/>
<Textarea.Toolbar>
<Textarea.Count id={countId} value={value} max={MAX} />
<div className="flex items-center gap-1">
<Textarea.Action aria-label="Attach a file">
<Icon icon={Paperclip} />
</Textarea.Action>
<Textarea.Action aria-label="Send">
<Icon icon={SendHorizontal} />
</Textarea.Action>
</div>
</Textarea.Toolbar>
</Textarea.Root>
);
}
Character count
Textarea.Count is the shared character-count readout: it shows length / max, escalates muted → warning → danger as the value nears and passes the limit, and announces the remaining count through a polite, atomic live region. Point the control's aria-describedby at it to join the count to the field.
"use client";
import { Field, Textarea } from "@stridge/noctis";
import { useId, useState } from "react";
const MAX = 160;
export default function TextareaCharCount() {
const [value, setValue] = useState("A short note that is creeping toward the limit so the count starts to warn.");
const countId = useId();
return (
<Field.Root className="w-full max-w-md">
<Textarea.Root>
<Textarea.Control aria-label="Note" aria-describedby={countId} value={value} onValueChange={setValue} />
<Textarea.Toolbar>
<span />
<Textarea.Count id={countId} value={value} max={MAX} />
</Textarea.Toolbar>
</Textarea.Root>
</Field.Root>
);
}
Invalid
Validity flows from the control: set aria-invalid (or let a Noctis Field.Root set it from validation) and the shell draws the danger border, holding it even when focused. There is no invalid prop to mirror on the shell — one source of truth — though the shell keeps an invalid escape hatch for shell-only styling.
"use client";
import { Field, Textarea } from "@stridge/noctis";
export default function TextareaInvalid() {
// No `invalid` on the shell: the danger border flows from the control's aria-invalid (shown here
// statically; a Base UI Field sets it from validation), and the error joins the field automatically.
return (
<Field.Root className="w-full max-w-md">
<Field.Label>Feedback</Field.Label>
<Textarea.Root>
<Textarea.Control defaultValue="too short" aria-invalid />
</Textarea.Root>
<Field.Error match>Please write at least a sentence.</Field.Error>
</Field.Root>
);
}
Read-only
A read-only field reads as quiet-but-present: a calm border, no resize handle, and selectable, focusable text — distinct from the dimmed, not-allowed disabled field beside it. It flows from the control's readOnly.
"use client";
import { Textarea } from "@stridge/noctis";
const SAMPLE = "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy…";
export default function TextareaReadOnly() {
return (
<div className="flex w-full max-w-md flex-col gap-3">
{/* Read-only: quiet, selectable, no resize handle — distinct from disabled. */}
<Textarea.Root>
<Textarea.Control aria-label="License (read-only)" readOnly defaultValue={SAMPLE} />
</Textarea.Root>
{/* Disabled: dimmed and not-allowed. */}
<Textarea.Root>
<Textarea.Control aria-label="License (disabled)" disabled defaultValue={SAMPLE} />
</Textarea.Root>
</div>
);
}
Disabled
A disabled field dims to the disabled opacity and blocks the text cursor. Disable the control alone — the shell reads it through :has() and dims to match.
"use client";
import { Textarea } from "@stridge/noctis";
export default function TextareaDisabled() {
// Disable the control alone — the shell reads it through :has() and dims to match.
return (
<Textarea.Root className="w-full max-w-md">
<Textarea.Control aria-label="Comment" placeholder="Commenting is closed" disabled />
</Textarea.Root>
);
}
Accessibility
- State flows from the control. The shell reads the control's
:disabled,aria-invalid, andreadonly(and Base UI/Fielddata-*) through:has()— one source of truth, no double-setting. - Pair it with a
Field. A NoctisField.Rootauto-associates the label, joins the description and error througharia-describedby, and setsaria-invalidfrom validation. - Calm, visible focus. Focus shifts the border to the accent focus role (no surrounding ring), on both keyboard and pointer focus the way a text field naturally highlights when active.
- Toolbar actions are real, labelled buttons.
Textarea.Actionrenders a<button type="button">; an icon-only action needs anaria-label. They follow the control in DOM order. - The count announces politely.
Textarea.Countexposes the remaining characters through anaria-live="polite",aria-atomicregion that starts empty, joined to the control viaaria-describedby. - Auto-grow is progressive. The field grows with content via CSS
field-sizingwhere supported, with a small script fallback elsewhere; the vertical resize handle is always available.
Anatomy
Textarea.Root— the block field shell. Paints the surface, border, rest shadow, and the focus border; owns thesize. Reads the control's state through:has(); keepsinvalid/disabledas additive shell-only overrides. Clicking its padding focuses the control.Textarea.Control— the editable textarea (Base UI's input rendered as a<textarea>). Auto-grows between the field's min and max height and keeps a resize handle; itsdisabled/readOnly/aria-invaliddrive the shell.Textarea.Toolbar— the optional footer row, divided from the text, hosting actions and a count.Textarea.Action/Textarea.Count— the shared in-field affordances (the same parts asInput.Action/Input.Count), stampingnoctis-field-action/noctis-field-count.
The shell parts stamp the data-slot values noctis-textarea, noctis-textarea-control, and noctis-textarea-toolbar; the toolbar hosts the shared noctis-field-action and noctis-field-count.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS. The minted tokens are the public override seam: set one on any ancestor and every textarea in that region retunes — e.g. .compact { --noctis-textarea-min-block-size: 3rem; }. See Customization for the full override ladder.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Textarea.Control extends the Base UI input props (rendered as a textarea), so onValueChange, defaultValue, rows, and the native textarea attributes pass straight through.