Form field
Labelling · error text + aria-describedby
A labelled input with programmatically associated help and error messages, and errors announced without a focus jump.
Live example
Real and interactive — use it with the mouse, or Tab to it and use the keys below.
We'll only use this to send your accessibility report.
Enter a valid email address, like name@example.com.
Clear the field or type a valid address, then tab or click away — the hint never disappears; the error (role="alert") joins it in aria-describedby instead of replacing it, and focus stays on the field the whole time.
Keyboard
| Key | Action |
|---|---|
| Tab / Shift + Tab | moves focus onto the control itself — the label is not a separate stop, only the wrapped field is |
| Ordinary typing / selection | behaves exactly as the wrapped native control would on its own; the wrapper adds no keyboard behaviour of its own |
Screen reader
- The label is announced as the field's accessible name because <label htmlFor> targets the control's id directly — visual proximity in the markup is not what creates that association.
- The hint is read as part of the field's description as soon as it exists, before any error is present.
- When an error appears, it is announced immediately as an alert; it does not replace the hint in the field's description — both ids stay joined in aria-describedby, so the next time the field is described (e.g. re-focused), both are read.
- aria-invalid is announced as an invalid entry only while a real error exists; it disappears — not "becomes false" — the moment the error clears.
ARIA notes
- label uses htmlFor pointing at the control's id, not implicit wrapping — the association keeps working for control types that don't nest cleanly inside a <label>.
- aria-describedby is the union of the hint id and the error id, joined by a space, never one replacing the other — the most common way real forms break this pattern is losing the hint's aria-describedby the instant the error's takes over, exactly when the hint is needed most.
- aria-invalid is set to true only when an error is actually present; it is omitted the rest of the time, not hardcoded to "false" — an explicit false is itself noise some screen readers announce, and it erases the one moment the attribute matters.
- The error text lives in role="alert", so it is announced without moving focus off the field the user is still typing into.
Code
The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.
import { cloneElement, useId, type ReactElement } from 'react'
// Generalises the `Field` wrapper already proven in production on
// /request-quote/ (src/components/LeadForm.tsx, A2-LEAD-FORM) — same
// label/htmlFor/error/role="alert" contract, extracted from a working example
// rather than designed from a spec (same precedent as Breadcrumbs, D-077).
//
// Two things LeadForm's Field didn't need yet, added here:
//
// 1. A hint, described ALONGSIDE the error, not replaced by it. LeadForm's
// Field only ever wires up `error` into aria-describedby; it has no hint
// concept at all. The single most common way real forms break this
// pattern is losing the hint's aria-describedby the instant an error's
// aria-describedby takes over — exactly the moment a user most needs
// to be reminded what a field is for. Both ids are joined, never one
// replacing the other.
//
// 2. Automatic wiring of id/aria-invalid/aria-describedby onto the control
// itself. LeadForm's six fields currently repeat that wiring by hand on
// every single <input>/<select> (id={`${formId}-x`}, aria-invalid=
// {!!errors.x}, aria-describedby={errors.x ? `${formId}-x-error` :
// undefined} — see LeadForm.tsx lines ~79-186). FormField closes that
// gap by construction: it clones `children` (the one real control) and
// injects those three attributes itself, the same cloneElement pattern
// this library already uses in Tooltip.tsx. A consumer cannot forget the
// wiring because there is no wiring left to forget.
//
// FormField never renders the control — <input>/<select>/<textarea>/a custom
// widget is passed in as `children`, exactly like LeadForm's Field. The
// library stays composable with whatever control a form actually needs
// instead of growing its own text-input primitive that would duplicate the
// site's existing `.input` class.
export function FormField({
label,
hint,
error,
id,
children,
}: {
label: string
hint?: string
error?: string
/** Supply your own id to match an existing naming scheme (e.g. a shared
* `${formId}-x` convention). Omit it and FormField generates one with
* useId() — safe to mount several instances on one page without id
* collisions. */
id?: string
children: ReactElement
}) {
const generatedId = useId()
const fieldId = id ?? generatedId
const hintId = hint ? `${fieldId}-hint` : undefined
const errorId = error ? `${fieldId}-error` : undefined
// Both hint and error described together when both exist — never one
// clobbering the other. Attribute is omitted entirely (not set to ""),
// when there is neither a hint nor an error to point at.
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined
const control = cloneElement(children, {
id: fieldId,
// true only when there is a real error — never a hardcoded "false".
// Screen readers treat an explicit aria-invalid="false" as noise, and it
// also erases the one moment this attribute is actually meaningful.
'aria-invalid': error ? true : undefined,
'aria-describedby': describedBy,
} as Record<string, unknown>)
return (
<div>
<label htmlFor={fieldId} className="block text-sm font-medium text-on-surface-variant">
{label}
</label>
{control}
{hint && (
<p id={hintId} className="mt-1 text-sm text-on-surface-variant">
{hint}
</p>
)}
{error && (
// role="alert" (not aria-live on a wrapper) so the error is announced
// the instant it renders, without moving focus off the field the
// user is still typing into — same mechanism LeadForm's Field
// already relies on.
<p id={errorId} role="alert" className="mt-1 text-sm font-medium text-[color:var(--color-critical)]">
{error}
</p>
)}
</div>
)
}
Accessibility pitfalls
AvoidThe hint's id is dropped from aria-describedby the moment an error appears.
DoJoin both ids — [hintId, errorId].filter(Boolean).join(' ') — so the hint stays available exactly when the user needs it most.
Avoidaria-invalid="false" is hardcoded on every field, error or not.
DoOmit aria-invalid entirely until there is a real error — an explicit "false" is noise, and it hides the one moment the attribute is meaningful.
AvoidThe error message exists in the DOM but nothing points a screen reader at it.
DoGive it an id inside aria-describedby (and role="alert", as here) — text that only sits visually near the field is invisible to anyone not looking at the screen.
AvoidFocus is moved to the error message, or to the top of the form, when validation fails.
DoLeave focus exactly where it was — the field the user is already in — and let role="alert" announce the error in the background.
AvoidA label element sits visually above the input with no htmlFor and no id on the input to match it.
DoUse <label htmlFor={id}> with a matching id on the control, so screen readers announce the name programmatically, not just sighted users seeing it nearby.