react.form.textarea
A labelled multi-line text box with hint, error and an optional live character or word count (GOV.UK).
React ^19 · TypeScript only A React component: use it from a TypeScript project with React installed. A client component ("use client").
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 16 tests, run in TypeScript.
What it does
A multi-line text box with its label, an optional hint and error message, and, when you give it a limit, GOV.UK's character count: a message after the box saying "You have 12 characters remaining" or "You have 3 words too many" as the person types. It is the GOV.UK Design System textarea and character count as one React component.
"use client";
import { Textarea } from "#fune/react.form.textarea@^1";
<Textarea id="more-detail" label="Can you provide more detail?"
hint="Do not include personal or financial information, like your National Insurance number or credit card details."
maxLength={500} threshold={75} error={errors.moreDetail}
value={detail} onChange={setDetail} />
For example
-
a plain textarea: five rows, submitted under its id
<Textarea id="more-detail" label="Can you provide more detail?" />renders
<div class="fune-field"><label class="fune-label" for="more-detail">Can you provide more detail?</label><textarea class="fune-textarea" id="more-detail" name="more-detail" rows="5"></textarea></div> -
a hint, a name of its own, rows, autocomplete and no spell check
<Textarea id="address" name="homeAddress" label="Address" hint="Include your postcode" rows={8} autoComplete="street-address" spellCheck={false} />renders
<div class="fune-field"><label class="fune-label" for="address">Address</label><div class="fune-hint" id="address-hint">Include your postcode</div><textarea class="fune-textarea" id="address" name="homeAddress" rows="8" autoComplete="street-address" spellCheck="false" aria-describedby="address-hint"></textarea></div>
The function
Written for React ^19, in TypeScript only. A component: it takes its props and renders HTML, which the tests below pin exactly.
A React capability: React ^19 · TypeScript only. This capability has no Rust implementation, so it is shown in TypeScript. A Rust project cannot use it: fune build stops and names where it was required. Your choice of Rust is kept for every other page.
export function Textarea(props: TextareaProps): JSX.Element
Its props, TextareaProps. A ? marks one the caller may leave out.
| label | ReactNode | the question, or the field's name |
| id? | string | the textarea's id; React's useId() when left out |
| name? | string | what the value is submitted as; the id when left out |
| hint? | ReactNode | help under the label |
| error? | ReactNode | what is wrong; marks the textarea invalid and describes it |
| value? | string | the text, for a controlled textarea (with onChange) |
| defaultValue? | string | the starting text, for an uncontrolled one |
| onChange? | (value: string) => void | called with the new text as the person types |
| onBlur? | () => void | called when the textarea loses focus, to mark the field touched |
| rows? | number | visible lines; 5 when left out |
| autoComplete? | string | the autocomplete token, such as street-address |
| spellCheck? | boolean | false for text a spell checker would mark wrong, such as codes |
| maxLength? | number | show a live count of characters against this limit |
| maxWords? | number | show a live count of words against this limit instead |
| threshold? | number | percentage of the limit below which the count is hidden |
| required? | boolean | the browser refuses an empty value |
| disabled? | boolean | shown but not editable, and not submitted |
| className? | string | added to the wrapper's classes |
| renders | JSX.Element |
The type it declares, generated into your project
/** One question answered in a paragraph or more. */
export interface TextareaProps {
/** the question, or the field's name */
readonly label: ReactNode;
/** the textarea's id; React's useId() when left out */
readonly id?: string;
/** what the value is submitted as; the id when left out */
readonly name?: string;
/** help under the label */
readonly hint?: ReactNode;
/** what is wrong; marks the textarea invalid and describes it */
readonly error?: ReactNode;
/** the text, for a controlled textarea (with onChange) */
readonly value?: string;
/** the starting text, for an uncontrolled one */
readonly defaultValue?: string;
/** called with the new text as the person types */
readonly onChange?: (value: string) => void;
/** called when the textarea loses focus, to mark the field touched */
readonly onBlur?: () => void;
/** visible lines; 5 when left out */
readonly rows?: number;
/** the autocomplete token, such as street-address */
readonly autoComplete?: string;
/** false for text a spell checker would mark wrong, such as codes */
readonly spellCheck?: boolean;
/** show a live count of characters against this limit */
readonly maxLength?: number;
/** show a live count of words against this limit instead */
readonly maxWords?: number;
/** percentage of the limit below which the count is hidden */
readonly threshold?: number;
/** the browser refuses an empty value */
readonly required?: boolean;
/** shown but not editable, and not submitted */
readonly disabled?: boolean;
/** added to the wrapper's classes */
readonly className?: string;
}
Your code names it in one line, in the file that uses it
import { Textarea } from "#fune/react.form.textarea@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
"use client";
import { useId, useState, type JSX, type ReactNode } from "react";
import { characterCount } from "./form_character_count.ts"; ← from form.character-count ^1.0.0 · built alongside by fune
import { FormField, fieldIds } from "./react_form_form_field.ts"; ← from react.form.form-field ^1.0.0 · built alongside by fune
import type { TextareaProps } from "./react_form_textarea_types.ts";
function present(node: ReactNode): boolean {
return node !== null && node !== undefined && node !== false && node !== "";
}
/**
* The GOV.UK textarea, and with maxLength or maxWords the GOV.UK character
* count: a message after the box, in a polite live region that describes it,
* saying how many characters or words are left. The box is never cut off at
* the limit (no maxlength attribute), so a person can paste and then edit
* down; it is marked in error while over.
*/
export function Textarea(props: TextareaProps): JSX.Element {
const auto = useId();
const { label, hint, error, value, defaultValue, onChange, onBlur, autoComplete, spellCheck, maxLength, maxWords, threshold, required, disabled, className } = props;
// The live count needs the text even when the textarea is uncontrolled.
const [typed, setTyped] = useState(defaultValue ?? "");
const id = props.id ?? auto;
if (maxLength !== undefined && maxWords !== undefined) {
throw new Error("a textarea counts characters or words, not both: give maxLength or maxWords");
}
if (threshold !== undefined && maxLength === undefined && maxWords === undefined) {
throw new Error("a threshold needs a limit: give maxLength or maxWords as well");
}
const limit = maxLength ?? maxWords;
const count = limit === undefined ? null : characterCount(value ?? typed, limit, maxWords !== undefined ? "words" : "characters", threshold ?? null);
const ids = fieldIds(id, present(hint), present(error));
const info = `${id}-info`;
const describedBy = count ? [ids.describedBy, info].filter(Boolean).join(" ") : ids.describedBy;
const wrapper = [count ? "fune-character-count" : null, className || null].filter(Boolean).join(" ");
return (
<FormField id={id} label={label} hint={hint} error={error} className={wrapper || undefined}>
<textarea
className={ids.error || count?.over ? "fune-textarea fune-textarea--error" : "fune-textarea"}
id={ids.control}
name={props.name ?? id}
rows={props.rows ?? 5}
value={value}
defaultValue={defaultValue}
autoComplete={autoComplete}
spellCheck={spellCheck}
required={required}
disabled={disabled}
aria-describedby={describedBy ?? undefined}
aria-invalid={ids.error ? true : undefined}
onChange={(event) => {
setTyped(event.target.value);
onChange?.(event.target.value);
}}
onBlur={onBlur ? () => onBlur() : undefined}
/>
{count ? (
<div
className={count.over ? "fune-character-count__message fune-character-count__message--over" : "fune-character-count__message"}
id={info}
hidden={!count.visible}
aria-live="polite"
>
{count.message}
</div>
) : null}
</FormField>
);
}Install
npm install react react-dom
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project) with React ^19 installed (Functional Weave does not ship it, and fune build stops with the npm install line if it is missing), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add react.form.textarea
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./react.form.textarea-1.0.1-typescript.fune, or fetch it from a terminal with fune pull react.form.textarea@1.0.1:typescript.
It is React ^19 · TypeScript only, so there is no package for Python or Rust. The full package is one file too: react.form.textarea-1.0.1.fune, 20,874 bytes, sha256 8348967dd13b86f1602175c65ed0df7137a1cb4a30fdd6ce147c9edc2f7f28f8.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before react.form.textarea
after — your function gets the result and the arguments, and returns the final result.
// fune: after react.form.textarea
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace form.character-count in react.form.textarea
// fune: replace react.form.form-field in react.form.textarea
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show react.form.textarea --steps.
// fune: step react.form.textarea after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript alone, with the React in tooling/react, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json. A component’s tests are render tests: the props go in, and the HTML renderToStaticMarkup makes of them has to match exactly; a React warning during the render fails the test.
-
a plain textarea: five rows, submitted under its id
<Textarea id="more-detail" label="Can you provide more detail?" />renders
<div class="fune-field"><label class="fune-label" for="more-detail">Can you provide more detail?</label><textarea class="fune-textarea" id="more-detail" name="more-detail" rows="5"></textarea></div> -
a hint, a name of its own, rows, autocomplete and no spell check
<Textarea id="address" name="homeAddress" label="Address" hint="Include your postcode" rows={8} autoComplete="street-address" spellCheck={false} />renders
<div class="fune-field"><label class="fune-label" for="address">Address</label><div class="fune-hint" id="address-hint">Include your postcode</div><textarea class="fune-textarea" id="address" name="homeAddress" rows="8" autoComplete="street-address" spellCheck="false" aria-describedby="address-hint"></textarea></div> -
an error marks the textarea invalid; a controlled value is its escaped text
<Textarea id="why" label="Why?" error="Enter why you are applying" value="Because & <so>" />renders
<div class="fune-field fune-field--error"><label class="fune-label" for="why">Why?</label><p class="fune-error-message" id="why-error"><span class="fune-visually-hidden">Error:</span> Enter why you are applying</p><textarea class="fune-textarea fune-textarea--error" id="why" name="why" rows="5" aria-describedby="why-error" aria-invalid="true">Because & <so></textarea></div> -
required and disabled, with three rows
<Textarea id="r" label="Reason" required disabled rows={3} />renders
<div class="fune-field"><label class="fune-label" for="r">Reason</label><textarea class="fune-textarea" id="r" name="r" rows="3" required="" disabled=""></textarea></div> -
a character count: the message follows the textarea, describes it and is a live region
<Textarea id="detail" label="Detail" maxLength={200} />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="detail">Detail</label><textarea class="fune-textarea" id="detail" name="detail" rows="5" aria-describedby="detail-info"></textarea><div class="fune-character-count__message" id="detail-info" aria-live="polite">You have 200 characters remaining</div></div> -
an uncontrolled default value is counted
<Textarea id="d" label="Detail" maxLength={10} defaultValue="Hello" />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="d">Detail</label><textarea class="fune-textarea" id="d" name="d" rows="5" aria-describedby="d-info">Hello</textarea><div class="fune-character-count__message" id="d-info" aria-live="polite">You have 5 characters remaining</div></div> -
over the limit: the box is styled in error but not aria-invalid, and nothing is cut off
<Textarea id="d" label="Detail" maxLength={5} value="abcdefgh" />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="d">Detail</label><textarea class="fune-textarea fune-textarea--error" id="d" name="d" rows="5" aria-describedby="d-info">abcdefgh</textarea><div class="fune-character-count__message fune-character-count__message--over" id="d-info" aria-live="polite">You have 3 characters too many</div></div> -
a word count with a hint, an error and a class: described by hint, error, then the count
<Textarea id="s" label="Summary" hint="Keep it short" error="Summary must be 3 words or fewer" maxWords={3} value="one two three four" className="wide" />renders
<div class="fune-field fune-field--error fune-character-count wide"><label class="fune-label" for="s">Summary</label><div class="fune-hint" id="s-hint">Keep it short</div><p class="fune-error-message" id="s-error"><span class="fune-visually-hidden">Error:</span> Summary must be 3 words or fewer</p><textarea class="fune-textarea fune-textarea--error" id="s" name="s" rows="5" aria-describedby="s-hint s-error s-info" aria-invalid="true">one two three four</textarea><div class="fune-character-count__message fune-character-count__message--over" id="s-info" aria-live="polite">You have 1 word too many</div></div> -
one word left is singular
<Textarea id="w" label="Title" maxWords={3} value="one two" />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="w">Title</label><textarea class="fune-textarea" id="w" name="w" rows="5" aria-describedby="w-info">one two</textarea><div class="fune-character-count__message" id="w-info" aria-live="polite">You have 1 word remaining</div></div> -
below the threshold the message is there but hidden
<Textarea id="t" label="Notes" maxLength={20} threshold={75} defaultValue="abcdefghij" />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="t">Notes</label><textarea class="fune-textarea" id="t" name="t" rows="5" aria-describedby="t-info">abcdefghij</textarea><div class="fune-character-count__message" id="t-info" hidden="" aria-live="polite">You have 10 characters remaining</div></div>
Show the other 6 tests
-
at the threshold the message shows
<Textarea id="t" label="Notes" maxLength={20} threshold={75} defaultValue="abcdefghijklmno" />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="t">Notes</label><textarea class="fune-textarea" id="t" name="t" rows="5" aria-describedby="t-info">abcdefghijklmno</textarea><div class="fune-character-count__message" id="t-info" aria-live="polite">You have 5 characters remaining</div></div> -
emoji count once each, so three fill a limit of three
<Textarea id="e" label="Mood" maxLength={3} value="😀😀😀" />renders
<div class="fune-field fune-character-count"><label class="fune-label" for="e">Mood</label><textarea class="fune-textarea" id="e" name="e" rows="5" aria-describedby="e-info">😀😀😀</textarea><div class="fune-character-count__message" id="e-info" aria-live="polite">You have 0 characters remaining</div></div> -
characters and words together are refused
<Textarea id="x" label="X" maxLength={10} maxWords={2} />error: a textarea counts characters or words, not both
-
a threshold without a limit is refused
<Textarea id="x" label="X" threshold={50} />error: a threshold needs a limit
-
a limit of 0 is refused
<Textarea id="x" label="X" maxLength={0} />error: limit must be a whole number of at least 1
-
an id with a space is refused
<Textarea id="more detail" label="More detail" />error: a field id cannot contain spaces
More from the author
- **rows** is 5 unless you say otherwise; make it fit the answer you expect. - **maxLength** counts characters (Unicode code points, so an emoji is one) and **maxWords** counts words; give one, not both. The count and its wording come from `form.character-count`, which your server can call with the same limit so the two never disagree. - **threshold** (a percentage) hides the message until the text reaches that share of the limit, for a limit most people will never get near. The message element stays in the page with `hidden`, so it still describes the box and the live region is already there when it appears. - The message sits **after** the textarea, is in the box's `aria-describedby` (after the hint and the error) and is an `aria-live="polite"` region, so a screen reader hears the count after a pause in typing rather than on every key. - Over the limit, the box gets `fune-textarea--error` and the message `fune-character-count__message--over`, but not `aria-invalid`: that is for the error message your validation shows after submitting. The box is never cut off at the limit (no `maxlength` attribute), as GOV.UK advises, so text can be pasted and edited down; validate the limit on the server. - **value** with **onChange** is controlled; **defaultValue** alone is uncontrolled and still counted live. `onChange` is called with the text, not the event; **onBlur** is called with nothing when the box loses focus, to mark the field touched (`form.state`'s blur). **id** is the textarea's id and, unless **name** says otherwise, its submitted name; left out, it is React's `useId()`.
It is a client component (it keeps the text for the count), so its file starts with `"use client"`.
Errors: `a textarea counts characters or words, not both`, `a threshold needs a limit`, and those of `form.character-count` (`limit must be a whole number of at least 1`) and `react.form.form-field` (an id with a space).
Classes: those of `react.form.form-field`, plus `fune-textarea` (and `fune-textarea--error`), `fune-character-count` on the wrapper when counting, `fune-character-count__message` (and `fune-character-count__message--over`).
Sources: GOV.UK Design System, "Textarea" https://design-system.service.gov.uk/components/textarea/ and "Character count" https://design-system.service.gov.uk/components/character-count/; govuk-frontend `character-count.mjs` for the wording and threshold https://github.com/alphagov/govuk-frontend/tree/main/packages/govuk-frontend/src/govuk/components/character-count.
## Notices
Portions derived from GOV.UK Frontend (https://github.com/alphagov/govuk-frontend), Copyright (c) 2017 Crown Copyright (Government Digital Service), under the MIT License; the full notice is in NOTICE.
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 1,217 |
| README.md | 3,575 |
| impl/typescript.tsx | 3,128 |
| vectors.json | 7,150 |