Textarea
Auto-growing multi-line input with an optional character counter.
import { Textarea }from "@/components/ui"
Specification
Collects multi-line text, growing to fit its content with an optional character counter.
Props (12)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Explicit element id, wired to the label. |
| label | string | — | Visible field label shown above the textarea. |
| hint | string | — | Helper text shown below the field when there is no error. |
| error | string | — | Error message; renders the field in the error state. |
| required | boolean | — | Marks the field required with a visual asterisk. |
| placeholder | string | — | Example input shown when empty. |
| maxLength | number | — | Character limit; drives the counter and native maxLength. |
| rows | number | 3 | Initial visible row count before auto-growing. |
| value | string | — | Controlled textarea value. |
| onChange | (e: ChangeEvent<HTMLTextAreaElement>) => void | — | Change handler; fires after any rule enforcement is applied. |
| onBlur | (e: FocusEvent<HTMLTextAreaElement>) => void | — | Blur handler; fires after rule-based formatting on blur. |
| rule | InputRule | — | Keystroke/blur validation rule that strips disallowed characters, caps length, and pretty-prints on blur. |
With counter
0/200
Full source
The complete component, read from
src/components/ui/textarea.tsx.textarea.tsx
"use client";
import { useRef, useEffect, useId, useCallback, type ChangeEvent, type FocusEvent } from "react";
import { cn } from "@/lib/utils";
import { Field, controlBase, controlClasses } from "./field";
import { enforceInputRule, formatOnBlur, type InputRule } from "@/lib/validation";
interface TextareaProps {
id?: string;
label?: string;
hint?: string;
error?: string;
required?: boolean;
placeholder?: string;
maxLength?: number;
rows?: number;
value?: string;
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
onBlur?: (e: FocusEvent<HTMLTextAreaElement>) => void;
/**
* Keystroke/blur validation rule (same model as `Input`). Strips disallowed
* characters and caps length on change; pretty-prints via `rule.format` on
* blur. See `@/lib/validation`.
*/
rule?: InputRule;
}
export function Textarea({
id,
label,
hint,
error,
required,
placeholder,
maxLength,
rows = 3,
value,
onChange,
onBlur,
rule,
}: TextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const reactId = useId();
const areaId = id ?? reactId;
const messageId = `${areaId}-message`;
const describedBy = error || hint ? messageId : undefined;
const effectiveMaxLength = maxLength ?? rule?.maxLength;
const handleChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
if (rule) {
const enforced = enforceInputRule(event.target.value, rule);
if (enforced !== event.target.value) event.target.value = enforced;
}
onChange?.(event);
},
[rule, onChange]
);
const handleBlur = useCallback(
(event: FocusEvent<HTMLTextAreaElement>) => {
if (rule?.format) {
const formatted = formatOnBlur(rule.format, event.target.value);
if (formatted !== event.target.value) {
event.target.value = formatted;
onChange?.(event as unknown as ChangeEvent<HTMLTextAreaElement>);
}
}
onBlur?.(event);
},
[rule, onChange, onBlur]
);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 220) + "px";
}, [value]);
const len = (value || "").length;
const counter = effectiveMaxLength
? { text: `${len}/${effectiveMaxLength}`, warn: len > effectiveMaxLength * 0.9 }
: undefined;
return (
<Field
label={label}
hint={hint}
error={error}
required={required}
htmlFor={areaId}
counter={counter}
messageId={messageId}
>
<textarea
ref={ref}
id={areaId}
rows={rows}
placeholder={placeholder}
value={value}
onChange={handleChange}
onBlur={handleBlur}
maxLength={effectiveMaxLength}
aria-invalid={Boolean(error) || undefined}
aria-describedby={describedBy}
className={cn(controlBase, controlClasses(error), "resize-y px-3 py-2 leading-relaxed")}
/>
</Field>
);
}Dependencies
To use Textarea you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ field.tsx └─ textarea.tsx src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/components/ui/field.tsx
field.tsx
"use client";
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface FieldCounter {
text: string;
warn?: boolean;
}
interface FieldProps {
label?: string;
hint?: string;
error?: string;
required?: boolean;
htmlFor?: string;
counter?: FieldCounter;
messageId?: string;
children: ReactNode;
}
export function Field({
label,
hint,
error,
required,
htmlFor,
counter,
messageId,
children,
}: FieldProps) {
const message = error || hint;
return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label
htmlFor={htmlFor}
className="flex items-center gap-1 text-[13px] font-medium text-text"
>
{label}
{required && (
<span className="text-error" aria-hidden>
*
</span>
)}
</label>
)}
{children}
<div className="flex items-start justify-between gap-3">
<p
id={messageId}
className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
>
{message || "\u00A0"}
</p>
{counter && (
<span
className={cn(
"shrink-0 text-xs tabular-nums",
counter.warn ? "text-error" : "text-text-tertiary"
)}
>
{counter.text}
</span>
)}
</div>
</div>
);
}
export const controlBase = cn(
"w-full rounded-lg border text-sm bg-surface text-text transition-colors",
"placeholder:text-text-tertiary",
"focus:outline-none focus-visible:outline-none focus-visible:border-accent",
"disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
"read-only:bg-bg-secondary"
);
export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
return cn(
error ? "border-error focus-visible:border-error" : "border-border",
readOnly && "read-only:bg-bg-secondary",
disabled && "text-text-tertiary"
);
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}