Rich Textarea
RichTextArea is FormItem + Label + Description + Textarea +
Message in one, with an optional character counter. It spreads textarea
HTML attributes, so {...field} from react-hook-form works. Everything
Headless UI wires inside a Field (for, aria-labelledby,
aria-describedby, aria-invalid, disabled) works here too.
import RichTextArea from '@zuilib/components/rich-textarea'
Label and description
Character count
showCharCount renders count / maxLength in the footer as a polite live
region. Over the limit the counter gets data-state="over", turns
--destructive and the control is marked invalid. Note that the native
maxLength also prevents typing past the limit; a controlled value set
programmatically can still exceed it.
Error and unsaved
error renders a Message (role="alert") in the footer and marks the
control invalid. unsaved tints the frame --input-dirty. buttonContent
sits bottom-right inside the frame.
Props
RichTextAreaProps extends the <textarea> attributes (value,
defaultValue, onChange, rows, placeholder, disabled, required,
…) with:
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Rendered as the Label, associated with the textarea |
description | string | — | Help text, in the textarea's aria-describedby |
error | string | — | Renders a Message (role="alert") in the footer, in aria-describedby; marks the control invalid |
showCharCount | boolean | false | Shows count / maxLength as a polite live region; data-state="over" and invalid past the limit |
maxLength | number | — | Native limit and the denominator of the counter |
unsaved | boolean | false | Tints the frame --input-dirty |
buttonContent | ReactNode | — | The Textarea's action slot, bottom-right inside the frame |
disabled | boolean | false | Passed to the FormItem so the label and control dim together |
className | string | — | Merged onto the root FormItem |
textareaClassName | string | — | Merged onto the native <textarea> |
The ref points at the native <textarea>.
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="rich-textarea"] | div | Root (the FormItem) |
[data-slot="rich-textarea-label"] | label | — |
[data-slot="rich-textarea-description"] | p | — |
[data-slot="textarea"] | div | The Textarea frame (full width) |
[data-slot="textarea-control"] | textarea | The native control |
[data-slot="textarea-actions"] | div | Wraps buttonContent |
[data-slot="rich-textarea-footer"] | div | Present with error or showCharCount |
[data-slot="rich-textarea-error"] | p | The Message, role="alert" |
[data-slot="rich-textarea-count"] | p | The counter; data-state="over" past the limit |
/* Consumer CSS: right-align the error too */
[data-slot="rich-textarea-footer"] {
justify-content: flex-end;
}
Tokens
| Token | Used for |
|---|---|
--input, --background, --ring | Textarea frame border, fill and focus ring |
--input-dirty | unsaved tint |
--destructive | Error message, invalid border, over-limit counter |
--muted-foreground | Description and counter |
--spacing | Vertical rhythm between the parts |
Accessibility
- The label is associated with the textarea through the
FormItem(for/id), so clicking it focuses the field. description,errorand the counter are all in the textarea'saria-describedby.error(or an over-limit count) setsaria-invalidon the control; the error is arole="alert"Message.- The counter is
aria-live="polite", so screen readers announce the count after typing pauses. disabledgoes through theFormItem, dimming the label and disabling the control natively.