Skip to the content.

Build an accessible form field

Build a FormField molecule that composes a label, a control, an optional hint and an optional error into one unit.

Starter files are in practice/workspace/form-field-molecule/<framework>/.

Solution

Approach 1: generated ids and aria-describedby

export default function FormField({ label, hint, error, required = false, children }) {
  const id = useId();
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className={`field${error ? " field--invalid" : ""}`}>
      <label className="field__label" htmlFor={id}>
        {label}{required && <span className="field__required"> (required)</span>}
      </label>
      {hint && <p className="field__hint" id={hintId}>{hint}</p>}
      {cloneElement(children, {
        id,
        required,
        "aria-describedby": describedBy,
        "aria-invalid": error ? "true" : undefined,
      })}
      {error && <p className="field__error" id={errorId} role="alert">{error}</p>}
    </div>
  );
}

aria-describedby takes a space-separated list, which is what lets the hint and the error both be read. Most implementations get this wrong by overwriting the hint’s id with the error’s, so the hint becomes unreachable at exactly the moment the user needs it most.

Approach 2: a render prop, so the field never touches the control

cloneElement is convenient and silently fails on a fragment or a wrapped child. Handing the ids back instead is explicit:

<FormField label="Email" hint="Work address" error={error}>
  {({ id, describedBy, invalid }) => (
    <input id={id} type="email" aria-describedby={describedBy} aria-invalid={invalid} />
  )}
</FormField>

More typing at every call site, and it works with any control — a native <input>, a third-party combobox, a group of radios where the “control” is a <fieldset> and the label is a <legend>. Angular’s equivalent is content projection plus an exported template context, which is why the Angular starter looks like this version rather than the first.

Trade-offs

aria-describedby vs aria-errormessage. The dedicated attribute is the semantically correct home for an error, and support is still thin enough that describedby is the pragmatic choice. Using both is safe and is what most component libraries settled on.

role="alert" on the error. It announces a validation failure the moment it appears, which is right on submit and noisy on every keystroke. Validate on blur and on submit, not on input, or the alert fires while the user is still typing their email address.

Colour is not a state. aria-invalid is what a screen reader and an automated test can both observe; a red border is what a sighted user sees. Shipping only the second is the most common accessibility failure in forms, and the cheapest to fix.

Required. An asterisk is a convention, not a word. Put “(required)” in the label text and let required on the control carry the programmatic meaning — then a screen reader reads the field once, correctly, instead of announcing “star”.