Developer Experience (DX)
Next, let's look at how developers use the Textarea. It has the Input's API, with the same names as every other component, it's typed end to end, and it works with your forms however you build them.
import { useState } from "react";
import { Textarea } from "@/components/Textarea";
export function Profile() {
const [bio, setBio] = useState("");
return (
<>
<label htmlFor="bio">Bio</label>
<p id="bio-hint">You can enter up to 160 characters</p>
<Textarea
id="bio"
limit={160}
aria-describedby="bio-hint"
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
</>
);
}
Consistent
Once you've learned the Input's API, you already know most of the Textarea's. These names mean the same thing everywhere in Open Components:
| Name | Kind | What it means |
|---|---|---|
size | Prop | xs, sm, md, lg or xl, with the same text and padding everywhere |
disabled | Prop | The component can't be used |
value | Prop | The value, which you bind the way your framework binds a form control's |
The rest are the Textarea's own: rows, the native attribute, autoGrow and maxRows for its height, and limit and countLabel for its count. We recommend size="md" and rows="3" as the defaults. The Textarea also leaves a few props out, on purpose:
- There's no
variantorcolor, for the same reasons as the Input: the fields in a form are all equally important, a field without a border would be invisible when it's empty, and its one coloured state, invalid, comes fromaria-invalid. - There's no
label,hintorerror. Each of them is an element of its own, linked by ID, which the Field on our Roadmap will render. - There's no
maxlengtheither.limittakes its place, since it counts without cutting anything off. The native attribute still lands on the<textarea>if you pass it, but we recommend against it (see Don't cut people off). - There's no
resize. A textarea people resize on the block axis, or one that grows on its own, covers what people need, and turning resizing off without growing leaves them stuck with whatever height you picked. The one exception is a disabled textarea, which has no resize handle, since nobody can write in it then. - There are no
leading,trailingoractionsslots. A textarea holds plain text over several lines, so an icon or a button inside it would sit in the way of what people write. Put buttons, like Send, after it in the form.
Type-safe
Props and events are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like rows="three" or size="medium".
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | How big its text and padding are |
rows | number | 3 | How many lines it shows, and is never shorter than |
autoGrow | boolean | false | Grows with its text instead of scrolling, and can't be resized |
maxRows | number | How many lines it grows to with autoGrow, before it scrolls | |
limit | number | The most characters it takes, which shows a count and never cuts people off | |
countLabel | (remaining: number) => string | The count's text, for the characters left, which are negative over the limit | |
disabled | boolean | false | Blocks focus, editing and submission |
value | string | The value, when you bind it. Without it, the textarea keeps its own |
| Event | Payload | Description |
|---|---|---|
input | Event | Fires as people type |
change | Event | Fires when people commit a change, like when they leave the textarea |
Both come straight from the <textarea>, like every other native event, such as focus and blur, and a binding to value listens to them for you. The Textarea has no slots.
We also export TextareaProps, which comes in handy for wrappers and data-driven forms:
import type { TextareaProps } from "@/components/Textarea";
const fields: (TextareaProps & { name: string; label: string })[] = [
{ name: "bio", label: "Bio", limit: 160 },
{ name: "address", label: "Street address", rows: 3 },
];
Composable
The Textarea renders two elements, so it splits what you pass between them, just like the Input. Classes and styles go on the root, so a class can set its variables and its width. Everything else lands on the <textarea>, including id, name, placeholder, required, readonly, autocomplete, spellcheck, dir, ARIA attributes and event listeners. A ref to the Textarea reaches the <textarea> too, since that's what form libraries and focus() need.
Forms
The <textarea> is a real form control, so the Textarea works with your forms however you build them. You can bind its value, as above, or leave it to the browser and read it from the form when it's sent, by its name. That also means the form works before your JavaScript loads.
import type { FormEvent } from "react";
export function Support() {
function send(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
api.send(data.get("subject"), data.get("message"));
}
return (
<form onSubmit={send}>
<label htmlFor="subject">Subject</label>
<Input id="subject" name="subject" />
<label htmlFor="message">Message</label>
<Textarea id="message" name="message" rows={5} />
<Button type="submit" color="primary">Send</Button>
</form>
);
}
Form libraries work the same way. They register the <textarea> itself, through its ref or its attributes and listeners, which is why the Textarea puts them all on the <textarea>.
With other components
- The Input takes a single line, and the Textarea several, with the same sizes, padding, border and states, so the two sit side by side in a form.
- The Field, on our Roadmap, will render the label, hint and error message around a Textarea, give it an
id, and set itsaria-describedbyandaria-invalidfor you, keeping the count after them. - A Button after the textarea sends the form. It's
type="submit", sinceEnterin a textarea starts a new line rather than submitting. - The count announces itself through the same
announceas the Button's loading state, so the whole page shares one live region.
Controllable
The Textarea doesn't keep any state you can't reach. The value is yours to bind, or to leave to the browser, and every other state is a prop or an attribute that you control:
| State | How you control it |
|---|---|
| Value | value, bound to your state, or left to the browser |
| Height | rows, autoGrow and maxRows |
| Disabled | disabled |
| Read-only | readonly, which lands on the <textarea> |
| Invalid | aria-invalid, set while its error message shows, or by the Textarea over its limit |
| Focus | A ref to its <textarea>, and focus() |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
textarea/shared-vocabulary | Must | Component | It uses the shared names size, disabled and value, with the Input's sizes | Open Components | Review |
textarea/typed | Must | Component | Props and events are typed, with unions rather than string, and rows, maxRows and limit take numbers | Open Components | Type check |
textarea/control-attributes | Must | Component | Attributes, listeners and refs land on the <textarea>, while classes and styles land on the root | Open Components | Unit test |
textarea/form-control | Must | Component | Its form sends its text under its name, whether the value is bound or not | HTML | Unit test |
textarea/controllable | Should | Component | Its value can be bound or left to the browser, and every other state is a prop or an attribute | Open Components | Unit test |
FAQ
<textarea>, so even before the page hydrates, people can write in it, resize it and send its form. Only the parts that need JavaScript have to wait for it, which are your bound value, the count and auto-growing in browsers without field-sizing. In browsers without typed attr(), the minimum height of rows and the maximum of maxRows wait for it too. Until then, the rows attribute sets the height, except on an auto-growing textarea in a browser with field-sizing, which starts at the height of its text.