Inputs
Textarea
A multi-line text input with its label, hint and error that grows with what is typed.
- Built on
<textarea>field-sizing<label for>aria-describedby:user-invalid- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.7 kB
- Dependencies
- React
Shown on the changelog.
Try its options
Loading its options.
Code
import { Textarea } from "@/components/equip/textarea";
export default function Demo() {
return (
<div style={{ width: 200 }}>
<Textarea
id="textarea-demo-release-notes"
label="Release notes"
rows={2}
hint="Shown on the changelog."
defaultValue="Fixes CSV export over 10k rows."
/>
</div>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="width:200px">
<div class="eq-textarea">
<label class="eq-textarea-label" for="textarea-demo-release-notes">Release notes</label>
<textarea rows="2" style="--_rows:2" id="textarea-demo-release-notes" class="eq-textarea-input" aria-describedby="textarea-demo-release-notes-hint">Fixes CSV export over 10k rows.</textarea>
<p class="eq-textarea-hint" id="textarea-demo-release-notes-hint">Shown on the changelog.</p>
</div>
</div>Examples
Grows as you type
Type a few lines: the box grows with the text up to twelve lines, then scrolls, with no script involved.
Code
import { Textarea } from "@/components/equip/textarea";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 300 }}>
<Textarea
id="textarea-grow-incident"
label="Incident summary"
rows={2}
placeholder="What happened, who was affected and what you changed."
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="width:100%;max-width:300px">
<div class="eq-textarea">
<label class="eq-textarea-label" for="textarea-grow-incident">Incident summary</label>
<textarea rows="2" placeholder="What happened, who was affected and what you changed." style="--_rows:2" id="textarea-grow-incident" class="eq-textarea-input"></textarea>
</div>
</div>Errors and disabled
The hint and the error are both read after the label, in that order.
Visible to the customer.
Write at least 20 characters.
Code
import { Textarea } from "@/components/equip/textarea";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 300 }}>
<Textarea
id="textarea-states-reason"
label="Reason for refund"
rows={2}
defaultValue="Duplicate"
hint="Visible to the customer."
error="Write at least 20 characters."
/>
<Textarea
id="textarea-states-policy"
label="Retention policy"
rows={2}
defaultValue="Logs are kept for 30 days, then deleted."
disabled
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:100%;max-width:300px">
<div class="eq-textarea">
<label class="eq-textarea-label" for="textarea-states-reason">Reason for refund</label>
<textarea rows="2" style="--_rows:2" id="textarea-states-reason" class="eq-textarea-input" aria-describedby="textarea-states-reason-hint textarea-states-reason-error" aria-invalid="true">Duplicate</textarea>
<p class="eq-textarea-hint" id="textarea-states-reason-hint">Visible to the customer.</p>
<p class="eq-textarea-error" id="textarea-states-reason-error">Write at least 20 characters.</p>
</div>
<div class="eq-textarea">
<label class="eq-textarea-label" for="textarea-states-policy">Retention policy</label>
<textarea rows="2" disabled style="--_rows:2" id="textarea-states-policy" class="eq-textarea-input">Logs are kept for 30 days, then deleted.</textarea>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Links the label, hint and error to the textarea without client JavaScript. |
label | ReactNode | none | Visible label above the textarea. |
hint | ReactNode | none | Help text under the textarea, read as its description. |
error | ReactNode | none | Error text. Sets aria-invalid and adds the text to the description. |
rows | number | 3 | Minimum height in lines. Also the fixed height where auto-grow is not supported. |
className | string | none | Added to the root wrapper, not the textarea. |
...rest | ComponentProps<"textarea"> | none | Every native textarea attribute and ref, applied to the textarea. |
Anatomy
| root | .eq-textarea | Wrapper that stacks label, textarea and messages. |
|---|---|---|
| label | .eq-textarea-label | |
| input | .eq-textarea-input | The native textarea. |
| hint | .eq-textarea-hint | |
| error | .eq-textarea-error |
Notes
- Renders on the server with no client JavaScript; pass value and onChange from a client component as you would to a native textarea.
- Grows from rows lines up to twelve lines, then scrolls, with no script measuring the text.
- The hint and error are joined into aria-describedby, after any aria-describedby you pass yourself.
- The border turns red when error is set, and also through :user-invalid once someone has edited a textarea that breaks a native constraint such as required or minlength.
- field-sizing: content is not yet in every engine. Where it is missing, the textarea keeps the height set by rows and can be resized vertically by its corner handle.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/textarea.tsx64 lines
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import "./textarea.css";
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
export type TextareaProps = ComponentProps<"textarea"> & {
/** Ties the label, hint and error to the textarea. Required so the part can render on the server. */
id: string;
/** Visible label. Always rendered, never a placeholder stand-in. */
label: ReactNode;
/** Help text under the textarea, announced as its description. */
hint?: ReactNode;
/** Error text. When set, the textarea is marked invalid and the text is announced with it. */
error?: ReactNode;
};
export function Textarea({
id,
label,
hint,
error,
className,
rows = 3,
style,
"aria-describedby": describedBy,
"aria-invalid": ariaInvalid,
...rest
}: TextareaProps) {
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = present(error) ? `${id}-error` : undefined;
const described = [describedBy, hintId, errorId].filter(Boolean).join(" ") || undefined;
// field-sizing ignores rows, so the CSS reads the same number as a minimum height.
const rowsStyle = { "--_rows": rows, ...style } as CSSProperties;
return (
<div className={["eq-textarea", className].filter(Boolean).join(" ")}>
<label className="eq-textarea-label" htmlFor={id}>
{label}
</label>
<textarea
rows={rows}
{...rest}
style={rowsStyle}
id={id}
className="eq-textarea-input"
aria-describedby={described}
aria-invalid={present(error) ? true : ariaInvalid}
/>
{present(hint) && (
<p className="eq-textarea-hint" id={hintId}>
{hint}
</p>
)}
{present(error) && (
<p className="eq-textarea-error" id={errorId}>
{error}
</p>
)}
</div>
);
}components/equip/textarea.css88 lines
@layer equip {
.eq-textarea {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-textarea-label {
width: fit-content;
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
color: var(--eq-ink);
}
.eq-textarea-input {
box-sizing: border-box;
display: block;
width: 100%;
min-height: var(--eq-h-m);
margin: 0;
padding: var(--eq-space-2) var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.45 var(--eq-font);
resize: vertical;
transition: border-color var(--eq-dur) var(--eq-ease);
}
/* Grows with its content between the rows minimum and twelve lines, then scrolls.
Without support, rows sets a fixed height and the corner handle resizes it. */
@supports (field-sizing: content) {
.eq-textarea-input {
field-sizing: content;
min-block-size: calc(var(--_rows, 3) * 1lh + 2 * var(--eq-space-2) + 2px);
max-block-size: calc(12lh + 2 * var(--eq-space-2) + 2px);
resize: none;
}
}
.eq-textarea-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
@media (hover: hover) {
/* :where keeps this below the invalid rules, so a red border stays red on hover. */
.eq-textarea-input:hover:where(:not(:disabled)) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-textarea-input:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-textarea-input[aria-invalid="true"] {
border-color: var(--eq-danger);
}
/* Native constraints (required, minlength) only after the person has interacted. */
.eq-textarea-input:user-invalid {
border-color: var(--eq-danger);
}
.eq-textarea-input:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-textarea-hint,
.eq-textarea-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-textarea-hint {
color: var(--eq-ink-2);
}
.eq-textarea-error {
color: var(--eq-danger);
}
}