react.form.form-field
Wire a label, hint and error message to one form control by id, the accessible way (GOV.UK form group pattern).
React ^19 · TypeScript only A React component: use it from a TypeScript project with React installed. Renders on the server; ships no JavaScript of its own.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript.fieldIds 9 · FormField 10
What it does
The wiring every accessible form field needs, once: a `<label>` whose `for` names the control, a hint and an error message with ids of their own, and the control's `aria-describedby` listing them, hint first. `FormField` renders the GOV.UK Design System form group around any control you give it as children; `fieldIds` gives the same ids to the control. Every other `react.form` component is built on these two.
import { FormField, fieldIds } from "#fune/react.form.form-field@^1";
const ids = fieldIds("colour", true, Boolean(error));
<FormField id="colour" label="Favourite colour" hint="Pick one" error={error}>
<input id={ids.control} name="colour" aria-describedby={ids.describedBy ?? undefined} aria-invalid={error ? true : undefined} />
</FormField>
The functions
A group: 2 functions that work together, each in its own file, each pinned by its own tests, written for React ^19. A project can install only the ones it calls.
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.
- fieldIds (id: string, hasHint: bool, hasError: bool) -> FieldIds
- FormField (props: FormFieldProps) -> element
The types it declares, generated into your project
/** The ids that tie a control to its label, hint and error message. */
export interface FieldIds {
/** the control's own id, which the label's for= names */
readonly control: string;
/** <id>-hint when there is a hint, else null */
readonly hint: string | null;
/** <id>-error when there is an error, else null */
readonly error: string | null;
/** the control's aria-describedby: the hint's id, then the error's, or null when neither */
readonly describedBy: string | null;
}
/** A label, a hint and an error message around one control. */
export interface FormFieldProps {
/** the control's id; the hint is <id>-hint and the error <id>-error */
readonly id: string;
/** the question the field asks, or its name */
readonly label: ReactNode;
/** help under the label, such as a format ("For example, 27 3 2007") */
readonly hint?: ReactNode;
/** what is wrong and how to fix it; the field is shown in error */
readonly error?: ReactNode;
/** the control, which takes aria-describedby from fieldIds */
readonly children?: ReactNode;
/** added to the wrapper's classes */
readonly className?: string;
}
Once installed, your code imports each one from the group's module.
fieldIds throws on bad input 9 tests
export function fieldIds(id: string, hasHint: boolean, hasError: boolean): FieldIds
| id | string | the control's id: not empty, no spaces |
| hasHint | bool | whether the field shows a hint |
| hasError | bool | whether the field shows an error message |
| returns | FieldIds | every id the field uses, and the control's aria-describedby |
For example
fieldIds(email, false, false)→ control email, hint —, error —, described by — no hint and no error: nothing describes the controlfieldIds(email, true, false)→ control email, hint email-hint, error —, described by email-hint a hint describes the controlfieldIds(email, false, true)→ control email, hint —, error email-error, described by email-error an error describes the control
import { fieldIds } from "#fune/react.form.form-field@^1";
import type { FieldIds } from "./react_form_form_field_types.ts";
/**
* The ids a form field uses, from the control's id: the hint is <id>-hint,
* the error <id>-error, and the control is described by the hint first and
* then the error, which is the order a screen reader reads them after the
* label (GOV.UK Design System, "Error message").
*/
export function fieldIds(id: string, hasHint: boolean, hasError: boolean): FieldIds {
if (id === "") throw new Error("a field needs an id: the label, hint and error are tied to the control by it");
if (/\s/.test(id)) throw new Error(`a field id cannot contain spaces ("${id}"): aria-describedby is a list of ids separated by spaces`);
const hint = hasHint ? `${id}-hint` : null;
const error = hasError ? `${id}-error` : null;
const described = [hint, error].filter((x) => x !== null).join(" ");
return { control: id, hint, error, describedBy: described === "" ? null : described };
}FormField 10 tests
export function FormField(props: FormFieldProps): JSX.Element
Its props, FormFieldProps. A ? marks one the caller may leave out.
| id | string | the control's id; the hint is <id>-hint and the error <id>-error |
| label | ReactNode | the question the field asks, or its name |
| hint? | ReactNode | help under the label, such as a format ("For example, 27 3 2007") |
| error? | ReactNode | what is wrong and how to fix it; the field is shown in error |
| children? | ReactNode | the control, which takes aria-describedby from fieldIds |
| className? | string | added to the wrapper's classes |
| renders | JSX.Element |
For example
-
a label and the control, nothing else
<FormField id="name" label="Full name">[control]</FormField>renders
<div class="fune-field"><label class="fune-label" for="name">Full name</label>[control]</div> -
a hint goes under the label, with its id
<FormField id="dob" label="Date of birth" hint="For example, 27 3 2007" > [control] </FormField>renders
<div class="fune-field"><label class="fune-label" for="dob">Date of birth</label><div class="fune-hint" id="dob-hint">For example, 27 3 2007</div>[control]</div>
import { FormField } from "#fune/react.form.form-field@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import type { JSX, ReactNode } from "react";
import { fieldIds } from "./react_form_form_field_field_ids.ts"; ← fieldIds, another function of this group · built into the same file, even by a slim install
import type { FormFieldProps } from "./react_form_form_field_types.ts";
// Whether a label, hint or error was given: null, undefined, false and ""
// render nothing, so they are not there.
function present(node: ReactNode): boolean {
return node !== null && node !== undefined && node !== false && node !== "";
}
/**
* The GOV.UK form group: the label, then the hint, then the error message,
* then the control, with the ids fieldIds gives. The control is the children;
* it takes aria-describedby and aria-invalid from the same ids, which every
* react.form component does for you.
*/
export function FormField({ id, label, hint, error, children, className }: FormFieldProps): JSX.Element {
const ids = fieldIds(id, present(hint), present(error));
const classes = ["fune-field", ids.error ? "fune-field--error" : null, className || null].filter(Boolean).join(" ");
return (
<div className={classes}>
<label className="fune-label" htmlFor={ids.control}>
{label}
</label>
{ids.hint ? (
<div className="fune-hint" id={ids.hint}>
{hint}
</div>
) : null}
{ids.error ? (
<p className="fune-error-message" id={ids.error}>
<span className="fune-visually-hidden">Error:</span> {error}
</p>
) : null}
{children}
</div>
);
}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 nothing else, 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.form-field
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add react.form.form-field --only fieldIds
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./react.form.form-field-1.0.1-typescript.fune, or fetch it from a terminal with fune pull react.form.form-field@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.form-field-1.0.1.fune, 16,585 bytes, sha256 11aa9cbbcc1bda588032b5d068a107d3393a8f6ea7b19bf2b8420590b98b2a76.
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.form-field.fieldIds
// fune: before react.form.form-field.FormField
after — your function gets the result and the arguments, and returns the final result.
// fune: after react.form.form-field.fieldIds
// fune: after react.form.form-field.FormField
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show react.form.form-field --steps.
// fune: step react.form.form-field.<fn> 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.
fieldIds 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| no hint and no error: nothing describes the control | email, false, false | → | control email, hint —, error —, described by — |
| a hint describes the control | email, true, false | → | control email, hint email-hint, error —, described by email-hint |
| an error describes the control | email, false, true | → | control email, hint —, error email-error, described by email-error |
| hint first, then error, as a screen reader should read them | email, true, true | → | control email, hint email-hint, error email-error, described by email-hint email-error |
| an id with dashes and digits is kept as it is | address-line-2, true, false | → | control address-line-2, hint address-line-2-hint, error —, described by address-line-2-hint |
| a React useId id (colons and underscores) works | _R_1_, false, true | → | control _R_1_, hint —, error _R_1_-error, described by _R_1_-error |
| a one-letter id | x, true, true | → | control x, hint x-hint, error x-error, described by x-hint x-error |
| an empty id is refused | , true, false | → | error: a field needs an id |
| an id with a space is refused, since aria-describedby splits on spaces | first name, false, false | → | error: a field id cannot contain spaces ("first name") |
FormField 10 tests
-
a label and the control, nothing else
<FormField id="name" label="Full name">[control]</FormField>renders
<div class="fune-field"><label class="fune-label" for="name">Full name</label>[control]</div> -
a hint goes under the label, with its id
<FormField id="dob" label="Date of birth" hint="For example, 27 3 2007" > [control] </FormField>renders
<div class="fune-field"><label class="fune-label" for="dob">Date of birth</label><div class="fune-hint" id="dob-hint">For example, 27 3 2007</div>[control]</div> -
an error marks the field and says Error: to a screen reader
<FormField id="email" label="Email address" error="Enter an email address" > [control] </FormField>renders
<div class="fune-field fune-field--error"><label class="fune-label" for="email">Email address</label><p class="fune-error-message" id="email-error"><span class="fune-visually-hidden">Error:</span> Enter an email address</p>[control]</div> -
hint and error together, hint first
<FormField id="phone" label="Phone" hint="For international numbers include the country code" error="Enter a phone number, like 01632 960 001" > [control] </FormField>renders
<div class="fune-field fune-field--error"><label class="fune-label" for="phone">Phone</label><div class="fune-hint" id="phone-hint">For international numbers include the country code</div><p class="fune-error-message" id="phone-error"><span class="fune-visually-hidden">Error:</span> Enter a phone number, like 01632 960 001</p>[control]</div> -
an empty hint and a null error are not shown
<FormField id="town" label="Town" hint="" error={null} > [control] </FormField>renders
<div class="fune-field"><label class="fune-label" for="town">Town</label>[control]</div> -
a class name is added to the wrapper's
<FormField id="town" label="Town" className="wide">[control]</FormField>renders
<div class="fune-field wide"><label class="fune-label" for="town">Town</label>[control]</div> -
with an error and a class name, the error class comes first
<FormField id="town" label="Town" className="wide" error="Enter a town" > [control] </FormField>renders
<div class="fune-field fune-field--error wide"><label class="fune-label" for="town">Town</label><p class="fune-error-message" id="town-error"><span class="fune-visually-hidden">Error:</span> Enter a town</p>[control]</div> -
text is escaped, never markup
<FormField id="q" label="<b>Search</b> & find"></FormField>renders
<div class="fune-field"><label class="fune-label" for="q"><b>Search</b> & find</label></div> -
a number is a node too
<FormField id="n" label={42} hint={0}>[control]</FormField>renders
<div class="fune-field"><label class="fune-label" for="n">42</label><div class="fune-hint" id="n-hint">0</div>[control]</div> -
an id with a space is refused
<FormField id="first name" label="First name" />error: a field id cannot contain spaces
More from the author
The order is the one GOV.UK uses: label, hint, error message, control. The error message starts with a visually hidden "Error:", so a screen reader user hears that it is an error and not more hint text. `fune-field--error` on the wrapper is the hook for the red bar a stylesheet draws.
`null`, `undefined`, `false` and `""` as a hint or an error mean there is none: nothing is rendered and nothing is described. Any other node, a number included, is shown.
Ids are passed in, never generated, so the server and the browser agree on them and the vectors are exact; the components built on this one fall back to React's `useId()` when you leave the id out. An id cannot contain a space, because `aria-describedby` is a list of ids separated by spaces.
Class names are stable and unstyled: `fune-field`, `fune-field--error`, `fune-label`, `fune-hint`, `fune-error-message`, `fune-visually-hidden`. `react.form.styles` is an optional stylesheet for them.
Sources: GOV.UK Design System, "Text input" (form group, hint and error message markup) https://design-system.service.gov.uk/components/text-input/ and "Error message" https://design-system.service.gov.uk/components/error-message/; WAI-ARIA 1.2, aria-describedby https://www.w3.org/TR/wai-aria-1.2/#aria-describedby.
## 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,219 |
| README.md | 2,370 |
| impl/typescript/field_ids.ts | 948 |
| impl/typescript/form_field.tsx | 1,452 |
| vectors.json | 5,322 |