Functional Weave
Code in Rust

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

  1. 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>
  2. 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.

labelReactNodethe question, or the field's name
id?stringthe textarea's id; React's useId() when left out
name?stringwhat the value is submitted as; the id when left out
hint?ReactNodehelp under the label
error?ReactNodewhat is wrong; marks the textarea invalid and describes it
value?stringthe text, for a controlled textarea (with onChange)
defaultValue?stringthe starting text, for an uncontrolled one
onChange?(value: string) => voidcalled with the new text as the person types
onBlur?() => voidcalled when the textarea loses focus, to mark the field touched
rows?numbervisible lines; 5 when left out
autoComplete?stringthe autocomplete token, such as street-address
spellCheck?booleanfalse for text a spell checker would mark wrong, such as codes
maxLength?numbershow a live count of characters against this limit
maxWords?numbershow a live count of words against this limit instead
threshold?numberpercentage of the limit below which the count is hidden
required?booleanthe browser refuses an empty value
disabled?booleanshown but not editable, and not submitted
className?stringadded to the wrapper's classes
rendersJSX.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";
impl/typescript.tsx · 70 lines · open · raw

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
Download for TypeScript react.form.textarea-1.0.1-typescript.fune · 20,902 bytes sha256 4fe37754d1e536ac91e70359555c73e91c82c108f477b5db8087273b8babf7b5

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.

  1. 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>
  2. 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>
  3. 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 &amp; &lt;so&gt;</textarea></div>
  4. 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>
  5. 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>
  6. 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>
  7. 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>
  8. 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>
  9. 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>
  10. 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
  1. 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>
  2. 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>
  3. characters and words together are refused

    <Textarea id="x" label="X" maxLength={10} maxWords={2} />

    error: a textarea counts characters or words, not both

  4. a threshold without a limit is refused

    <Textarea id="x" label="X" threshold={50} />

    error: a threshold needs a limit

  5. a limit of 0 is refused

    <Textarea id="x" label="X" maxLength={0} />

    error: limit must be a whole number of at least 1

  6. 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

PathBytes
NOTICE1,217
README.md3,575
impl/typescript.tsx3,128
vectors.json7,150