Textarea
SaveA multi-line text field with a label, helper text, errors, an optional character counter, and optional auto-grow.
- Category
- Core
- Version
- 0.1.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Textarea preview
Installation
npx @seamlessui/cli add textareaPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add textarea --dry-runUsage
textarea-example.tsx
import { Textarea } from "@/components/seamless/ui/textarea";
export default function TextareaExample() {
return (
<form className="grid w-full max-w-sm gap-4">
<Textarea
label="Bio"
placeholder="A sentence or two about you"
description="Shown on your public profile."
maxLength={160}
autoGrow
/>
<Textarea label="Release notes" defaultValue="Fixed" error="Describe at least one change." />
<Textarea label="Internal notes" defaultValue="Locked while the review is open." disabled />
</form>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | Visible label, linked to the textarea. Use aria-label when the label is shown elsewhere. |
| description | ReactNode | — | Helper text below the field, linked with aria-describedby. |
| error | ReactNode | — | Error message. Sets aria-invalid and replaces the description. |
| maxLength | number | — | Native character limit. Also turns on the character counter. |
| showCount | boolean | true | Shows the counter when maxLength is set. Set to false to keep the limit without a counter. |
| autoGrow | boolean | false | Grows with the content (CSS field-sizing: content) between 5rem and 16rem, then scrolls. Browsers without field-sizing fall back to rows. |
| rows | number | 3 | Initial height in lines when autoGrow is off. |
| containerClassName | string | — | Classes for the wrapper. className styles the textarea itself. |
| ...props | ComponentProps<"textarea"> | — | Any native textarea attribute, including value, defaultValue, onChange, and disabled. |
Accessibility
- The label is a native <label> bound to the textarea with an id generated by useId.
- Helper text, errors, and the counter are linked with aria-describedby; errors also set aria-invalid.
- The visible counter reads as "12 of 160 characters used" to screen readers when the field is focused.
- A polite live region announces the characters left only near the limit and when it is reached, so typing is not interrupted on every keystroke.
- At the limit the counter changes weight as well as color, and the numbers themselves show the limit.
- Focus shows a border and ring; transitions are disabled with reduced motion.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/textarea.tsxtextarea- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/textarea/0.1.0.json.
ui/textarea.tsxShowHide
ui/textarea.tsx
"use client";
import { type ChangeEvent, type ComponentProps, type ReactNode, useId, useState } from "react";
function cx(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
export interface TextareaProps extends ComponentProps<"textarea"> {
/** Visible label. Pass `aria-label` instead when the label is shown elsewhere. */
label?: ReactNode;
/** Helper text below the field. */
description?: ReactNode;
/** Error message. Marks the field invalid and replaces the description. */
error?: ReactNode;
/** Grows with its content between a minimum and maximum height, then scrolls. */
autoGrow?: boolean;
/** Shows a character counter when `maxLength` is set. */
showCount?: boolean;
/** Classes for the wrapping element. `className` styles the textarea itself. */
containerClassName?: string;
}
export function Textarea({
label,
description,
error,
autoGrow = false,
showCount = true,
maxLength,
value,
defaultValue,
onChange,
id,
rows = 3,
className,
containerClassName,
disabled,
"aria-describedby": describedBy,
...props
}: TextareaProps) {
const generatedId = useId();
const textareaId = id ?? generatedId;
const messageId = `${textareaId}-message`;
const counterId = `${textareaId}-counter`;
const invalid = error != null && error !== false;
const message = invalid ? error : description;
const [uncontrolledLength, setUncontrolledLength] = useState(() => String(defaultValue ?? "").length);
const length = value !== undefined ? String(value ?? "").length : uncontrolledLength;
const counting = showCount && maxLength !== undefined && maxLength > 0;
const remaining = counting ? Math.max(maxLength - length, 0) : 0;
// Announce only near the limit so screen readers are not interrupted on every keystroke.
const warnAt = counting ? Math.max(10, Math.ceil(maxLength * 0.1)) : 0;
const announcement = !counting
? ""
: remaining === 0
? "Character limit reached."
: remaining <= warnAt
? `${remaining} ${remaining === 1 ? "character" : "characters"} left.`
: "";
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
if (value === undefined) setUncontrolledLength(event.target.value.length);
onChange?.(event);
}
return (
<div className={cx("grid gap-1.5", containerClassName)}>
{label ? (
<label htmlFor={textareaId} className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{label}
</label>
) : null}
<textarea
id={textareaId}
rows={rows}
disabled={disabled}
maxLength={maxLength}
value={value}
defaultValue={defaultValue}
onChange={handleChange}
aria-invalid={invalid || undefined}
aria-describedby={[message ? messageId : null, counting ? counterId : null, describedBy].filter(Boolean).join(" ") || undefined}
className={cx(
"block w-full rounded-lg border bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm placeholder:text-zinc-400",
"transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none",
"focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:bg-zinc-50 disabled:opacity-60",
"dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:disabled:bg-zinc-900",
autoGrow ? "field-sizing-content min-h-20 max-h-64 resize-none overflow-y-auto" : "resize-y",
invalid
? "border-red-400 focus-visible:border-red-500 focus-visible:ring-red-500/20 dark:border-red-500/60"
: "border-zinc-300 dark:border-zinc-700",
className,
)}
{...props}
/>
{message || counting ? (
<div className="flex items-start justify-between gap-3">
{message ? (
<p id={messageId} className={invalid ? "text-sm text-red-600 dark:text-red-400" : "text-sm text-zinc-500 dark:text-zinc-400"}>
{message}
</p>
) : (
<span />
)}
{counting ? (
<p
id={counterId}
className={cx(
"shrink-0 text-xs tabular-nums",
remaining === 0 ? "font-medium text-red-600 dark:text-red-400" : "text-zinc-500 dark:text-zinc-400",
)}
>
<span aria-hidden="true">
{length}/{maxLength}
</span>
<span className="sr-only">
{length} of {maxLength} characters used.
</span>
</p>
) : null}
</div>
) : null}
{counting ? (
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
) : null}
</div>
);
}