Skip to content
Shenzhen · The Greater Bay Area · Earth

Accessible Form Patterns Are Four Primitives, Not a Component Library

Form accessibility failures reduce to four missing pieces, and all four are attributes rather than dependencies. Here is the label wiring, the error description order, the live region that stops going silent, and the CSS that carries state without colour.

9 min read1,888 words
Design EngineeringSystemsNot yet translated.

What are the four things that actually make a form accessible?

Nearly every form accessibility bug I have debugged in production reduces to one of four missing pieces: a label that exists as text and is programmatically tied to its control, an error that is rendered as text and referenced by the field it belongs to, a live region that announces state changes without stealing focus, and a state that is never signalled by colour alone. The rest of what gets sold under accessible form patterns — the ARIA role taxonomy, the focus trap, the wizard state machine — is either a consequence of those four or a much smaller slice of the problem than a component library implies.

I have built checkout forms with a schema library and internal approval forms with nothing but useState. The accessibility code was nearly identical both times, because a library solves validation orchestration and this work solves description. Description is four primitives, each one a handful of attributes.

Nine tenths of form accessibility is four things — a real label, a described error, a live region and no colour-only signalling — and none of them needs a library.

The four map onto named success criteria, which is useful because the mapping tells you where your risk actually sits. Most of the fix cost lands in Level A criteria, not the exotic ones.

PrimitiveWhat it satisfiesImplementation cost
A real label1.3.1 Info and Relationships (A), 3.3.2 Labels or Instructions (A)one <label for>, one id
A described error3.3.1 Error Identification (A), 3.3.3 Error Suggestion (AA)a rendered paragraph plus aria-describedby
A live region4.1.3 Status Messages (AA)one node mounted on first render
No colour-only signalling1.4.1 Use of Color (A), 1.4.11 Non-text Contrast (AA)a border, a glyph, or a word
Focus visible and unobscured2.4.7 Focus Visible (AA), 2.4.11 Focus Not Obscured (Minimum) (AA):focus-visible, no outline reset

Why is a real label not the same as a placeholder?

A placeholder is not a label. It disappears the moment the field has content, its contrast is typically below the 4.5:1 that body text needs, and whether a screen reader announces it as the accessible name depends on the browser and the user's settings. It also survives no translation pass cleanly, because a string that doubles as layout filler gets edited for length.

The mechanism that works is a <label> element with a matching for and id. The label becomes a click target for the control, which grows the touch target for everyone on a phone, and it makes the visible string and the announced string the same string by construction. Where a design genuinely cannot show a label above the field, keep the element and hide it visually — a clipped absolutely-positioned span still puts real text in the DOM. Reach for aria-label only when no text node can exist; it lives exclusively in the accessibility tree, so the visible copy and the announced copy drift apart the first time someone edits one of them.

Do not wrap the input in a <label> and also point a for at it. Two associations on one control produce a duplicated or concatenated name in some screen reader and browser pairings, and the bug is invisible to a sighted reviewer.

Two attributes most teams skip belong in the same conversation. autocomplete with a real token (email, one-time-code, cc-number, street-address) satisfies 1.3.5 Identify Input Purpose and hands the field to the browser's autofill, which is the single largest time saving available on any form and a prerequisite for several password managers. inputmode="numeric" plus enterkeyhint="next" decides which keyboard appears on mobile. Both are attributes.

Here is the label, hint and error wiring as I ship it; the load-bearing part is the order of ids in describedBy.

import { useId } from "react";

type FieldProps = {
  label: string;
  value: string;
  onChange: (value: string) => void;
  error?: string;
  hint?: string;
  type?: "text" | "email" | "tel";
};

export function Field({ label, value, onChange, error, hint, type = "text" }: FieldProps) {
  const id = useId();
  // Announcement follows attribute order, so the error id goes first:
  // "Enter a work email address. We use it once, to send the receipt."
  const describedBy = [error && `${id}-error`, hint && `${id}-hint`]
    .filter(Boolean)
    .join(" ");

  return (
    <div className="field" data-invalid={error ? "true" : undefined}>
      <label htmlFor={`${id}-input`}>{label}</label>
      {hint ? <p id={`${id}-hint`} className="field-hint">{hint}</p> : null}
      <input
        id={`${id}-input`}
        type={type}
        value={value}
        autoComplete="email"
        aria-invalid={error ? true : undefined}
        aria-describedby={describedBy || undefined}
        onChange={(event) => onChange(event.target.value)}
      />
      {error ? <p id={`${id}-error`} className="field-error">{error}</p> : null}
    </div>
  );
}

useId gives a stable id that survives hydration; hand-rolled counters break under React strict mode double rendering and under server rendering, which is why I stopped writing them.

How should an error be described, and when should it appear?

The timing matters as much as the markup. Validating on every keystroke tells a user they are wrong while they are still typing the thing that will make them right, and it is worse than useless for anyone using an input method editor. I type Chinese every day, and a field that validates on change fires on partial pinyin before the composition resolves. Read event.nativeEvent.isComposing or validate on compositionend before you decide a field is invalid. Validate on blur, re-validate on blur after a correction, and always on submit.

The error itself has to be a text node with an id, referenced from the control. A red ring around the input is a state, not a description; 3.3.1 wants the error identified in text, and 3.3.3 wants it to suggest a correction. "Invalid input" identifies nothing. "Enter a work email address, for example name@company.com" does both jobs in one line.

For long forms, pair it with a summary at the top that links to each invalid control, then move focus to the first invalid one on submit. Focus alone does not explain why it moved, so the description has to be in the DOM before the caret lands.

import type { FormEvent } from "react";

type FieldErrors = Record<string, string>;

export async function submit(
  event: FormEvent<HTMLFormElement>,
  setErrors: (errors: FieldErrors) => void,
  validate: (data: FormData) => Promise<FieldErrors>,
): Promise<void> {
  event.preventDefault();
  const form = event.currentTarget; // captured before the first await
  const errors = await validate(new FormData(form));
  setErrors(errors);

  const firstInvalid = Object.keys(errors)[0];
  if (!firstInvalid) return;

  // The error paragraph must be committed before focus moves, or the
  // description is absent when the user arrives at the control.
  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));

  const control = form.elements.namedItem(firstInvalid);
  if (control instanceof HTMLElement) {
    control.focus();
  }
}

One failure mode I have shipped myself: an error node that unmounts when the user starts typing, while focus is still inside the field. If focus is on an element that gets removed, focus falls to the document body, and the user's next keystroke goes nowhere. Keep the error node mounted and empty the text, or move focus deliberately.

Where does a live region go, and why does it go silent?

Screen readers register live regions when the node enters the accessibility tree, not when its text changes. A <p role="status"> that appears with its message is frequently missed, so the node must be present and empty on first render; only its text should change. role="status" already implies aria-live="polite"; adding both is noise, and in a couple of reader and browser combinations it produces a double announcement.

The second trap is idempotence. Repeating an identical string does not re-announce, so a "Saved" message that says "Saved" twice in a row is announced once. I put a counter or a timestamp inside the string for exactly that reason.

import { useEffect, useRef, useState } from "react";

type SaveState = "idle" | "saving" | "saved" | "failed";

export function SaveStatus({ state, attempt }: { state: SaveState; attempt: number }) {
  const [message, setMessage] = useState("");
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    // The attempt number is inside the sentence because an identical
    // string is a no-op for assistive technology.
    setMessage(
      state === "saved"
        ? `Draft saved. Attempt ${attempt}.`
        : state === "failed"
          ? `Save failed. Attempt ${attempt}. Retrying.`
          : "",
    );
    timer.current = setTimeout(() => setMessage(""), 4000);
    return () => {
      if (timer.current) clearTimeout(timer.current);
    };
  }, [state, attempt]);

  return (
    <p role="status" className="save-status">
      {message}
    </p>
  );
}

Reserve role="alert" and aria-live="assertive" for states that block the user, such as a session that is about to expire or a payment that failed. Interrupting a screen reader to say "draft saved" is a worse experience than saying nothing. And never put aria-live on a container that wraps the form: some readers re-read the whole subtree, and the result is a paragraph of noise on every keystroke.

What still fails when colour is the only signal?

Colour is usually the only thing that changes at the field. The message below it is text and therefore passes 1.4.1, but the field itself signals its state with a red border, and a user with deuteranopia sees a slightly darker grey outline. Fix it by changing something other than hue at the control: a thicker border on the inline start, plus a glyph in the message and the word "Error" or a specific instruction in the sentence.

Contrast is a separate budget. Error text needs 4.5:1 against its background; the border needs 3:1 against the adjacent colours under 1.4.11. Focus is not the same as hover, so :focus-visible with a 2px outline and a 2px offset, and never outline: none without a replacement. In forced-colors mode the browser discards your palette, so any state you encoded purely as a colour disappears with it.

.field { display: grid; gap: 0.375rem; }

.field-error {
  display: flex;
  align-items: flex-start;
  gap: 0.375rem;
  font-size: 0.875rem;
  line-height: 1.4;
  color: var(--color-danger-text);
}

/* A glyph and a rule carry the state when hue cannot. */
.field-error::before {
  content: "!";
  display: grid;
  place-items: center;
  inline-size: 1rem;
  block-size: 1rem;
  flex: none;
  font-weight: 700;
  border-radius: 50%;
  background: var(--color-danger-text);
  color: var(--color-surface-base);
}

.field[data-invalid="true"] input { border-inline-start: 3px solid var(--color-danger-text); }
.field input:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

@media (forced-colors: active) {
  .field[data-invalid="true"] input { border-inline-start-color: CanvasText; }
}

Two more colour-only habits worth deleting. An asterisk for required fields is announced as "star" or skipped entirely; put the word "required" in the label text or rely on the required attribute so it is exposed as state. A disabled submit button communicates that something is wrong and never says what; an enabled button that validates and reports is more work for you and less guessing for the user.

When is hand-rolling these primitives the wrong choice?

The four primitives hold for forms with stable shape. They stop holding when the shape is dynamic or the rules are cross-cutting, and pretending otherwise is how a maintained form becomes a file nobody wants to open.

SituationHand-rolled primitivesAdds a form library
One step, under twenty fieldsSufficient; the wiring above is the whole jobOverhead with no accessibility gain
Repeatable row groups (line items, dependants)Error state keyed per row rots within a quarterWorth it; index-keyed state is their core competence
Cross-field rules and conditional branchesEach rule becomes a bespoke effectWorth it, especially with a schema resolver
Resumable multi-step draftsThe state machine, not the markup, is the hard partWorth it if the draft lives on the server
Error copy authored by marketing or legalStraightforward: your own message registryCheck the library exposes per-code message overrides
Native constraint validation is acceptableCheaper still, but the messages are not styleableUsually unnecessary

Native browser validation deserves a fair hearing before you dismiss it. It is localised for free, it announces correctly, and it costs one attribute — but the timing is fixed, the message style is not yours, and you cannot describe a recovery step. For an internal admin form that is often a good trade. For a payment form it is not.

The primitives also do not cover everything. They say nothing about a 2.2 session timeout that discards twenty filled fields, nothing about 3.3.7 Redundant Entry when a user has to retype an address they already gave you, and nothing about 3.3.8 if your sign-in depends on a memory task or a drag puzzle. A form can carry all four primitives and still be unusable because a date picker only accepts a drag gesture, or because the only route to submit is behind a modal that traps focus incorrectly. If your product is used by people who file formal requests, and errors on those forms have real consequences, then 3.3.4 Error Prevention is the criterion that should drive the design and none of this article helps.

There is a second cost, and it is not accessibility. Every validation you add is attention the user pays per attempt, and a form that fails late charges it twice. That is the same arithmetic that decides whether a team keeps using any new tool at all — why a team abandons a tool that is technically correct is the adoption-side view of the identical ledger. Describing an error well reduces the number of attempts; describing it badly just makes each attempt slower.

What should you change on the form you are shipping this week?

Pick the form with the highest drop-off and wire the four primitives into it before you touch anything else: a <label for>, an error paragraph referenced from aria-describedby, one role="status" node mounted on first render, and a failure state that changes a border and a glyph rather than only a hue. Then run it with a keyboard alone for two minutes, including the error path, which is the part nobody rehearses. If that pass is clean, the remaining risk in your forms is almost certainly temporal — a timeout, a retry, a step you can never return to — and that is where I would spend the next hour.

Keep reading

More in Design Engineering

Ready to build a system?[ Book a Call ]