Skip to content

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.

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

KeyAction
Tab / Shift + Tabmoves focus onto the control itself — the label is not a separate stop, only the wrapped field is
Ordinary typing / selectionbehaves 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.

src/components/library/FormField.tsx
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.