Skip to content
inklu
reactformswcagdeveloper-workflowsaccessibility

Accessible forms in React: patterns that survive code review

Most React forms fail accessibility checks for the same five reasons. Here are the patterns we look for in PR review — and why each one matters for real users.

By Moe· Co-founder, Design9 min read

A team I was helping had a checkout form that looked clean. Labels above inputs, a tidy column of fields, an error summary at the top when something went wrong. Visual design review passed. QA passed. Then a customer who uses NVDA emailed support to say they had spent twenty minutes trying to give the company their money and given up.

The problem was not what the form looked like. The problem was what it announced. Field labels were <div> elements styled to look like labels, so screen readers heard "edit, blank" instead of "Email, edit, blank." Errors appeared visually next to the field but were not connected programmatically, so the screen reader user heard the error message read at the top of the form and then had no way to know which field it belonged to. The "required" asterisks were red and decorative — invisible to assistive tech.

Every one of these mistakes is common in React codebases. They pass design review because they look correct. They pass QA because nobody on the QA team turned on a screen reader. They fail in production because real users with disabilities show up and the form simply does not work for them.

This post is about the patterns I look for when reviewing a form PR — what the failure looks like, why it matters, and the JSX that fixes it.

The five things React forms get wrong

I have reviewed enough forms now that I can predict the failures before opening the diff. They cluster around five issues:

  1. Labels that are not labels
  2. Errors that are not associated with their field
  3. Required state that only exists visually
  4. Autocomplete attributes that are missing
  5. Focus that goes nowhere after submit

Most of the fixes are one-line changes. The reason they do not get made is that nobody is testing for them. axe-core will flag some, but the focus and submission patterns slip past static analysis because they only manifest at runtime.

Labels that are actually labels

The wrong way looks like this:

<div className="form-row">
  <div className="label">Email</div>
  <input type="email" name="email" />
</div>

The visual hierarchy is fine. The semantics are nonexistent. Clicking on the word "Email" does not focus the input (a sighted-mouse user UX bug), and screen readers will not announce the label when the input takes focus.

The fix is to use the actual <label> element with htmlFor pointing at an id:

<div className="form-row">
  <label htmlFor="email">Email</label>
  <input id="email" type="email" name="email" autoComplete="email" />
</div>

If you cannot afford a stable ID across renders, useId is built into React for exactly this:

import { useId } from "react";

function EmailField() {
  const id = useId();
  return (
    <div className="form-row">
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" name="email" autoComplete="email" />
    </div>
  );
}

The placeholder-as-label pattern — where a field has no visible label and uses the placeholder text instead — is its own special failure. It violates WCAG 3.3.2 (Labels or Instructions), disappears the moment the user starts typing, and usually has color contrast below 4.5:1 to the input background. Do not ship it.

Errors that travel with their field

A common React pattern: validation produces an array of errors that gets rendered in a summary box above the form. The field with the error gets a red border. Done.

A screen reader user gets none of that. The red border is invisible to them, and the summary is disconnected from the field unless you explicitly connect them. The fix has two parts.

First, link each error message to its input using aria-describedby:

function PasswordField({ error }) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div className="form-row">
      <label htmlFor={id}>Password</label>
      <input
        id={id}
        type="password"
        name="password"
        aria-describedby={error ? errorId : undefined}
        aria-invalid={error ? "true" : "false"}
      />
      {error && (
        <p id={errorId} className="error">
          {error}
        </p>
      )}
    </div>
  );
}

Now when the user focuses the field, the screen reader announces "Password, invalid entry, edit, password too short."

Second, on submit failure, move focus to the first invalid field. Users who use only the keyboard or assistive tech otherwise have to hunt around the form to find where the problem is:

function onSubmit(e) {
  e.preventDefault();
  const errors = validate(values);
  setErrors(errors);

  if (Object.keys(errors).length) {
    const firstErrorField = Object.keys(errors)[0];
    document.getElementById(firstErrorField)?.focus();
  }
}

If you prefer to announce a summary rather than move focus, render it in a live region (role="alert" or aria-live="assertive") so it gets read aloud when it appears. But always make sure each individual error is also linked to its field via aria-describedby — the summary alone is not enough.

Required state for everyone

A red asterisk next to a label is a convention that means "required" to sighted users who know the convention. It means nothing to screen reader users, and it is invisible to anyone with reduced color vision if the asterisk is the only indicator.

The fix is to use the required attribute and, if you also want visual signaling, mark the asterisk as decorative so it does not get announced twice:

<label htmlFor={id}>
  Email
  <span aria-hidden="true" className="required-marker">*</span>
</label>
<input id={id} type="email" name="email" required autoComplete="email" />

The required attribute alone is enough for WCAG. The visual asterisk is purely a sighted-user affordance. If you skip the asterisk and rely on required, you are still compliant — but a sighted user will not know which fields are mandatory until they try to submit, which is a usability problem regardless of accessibility.

Autocomplete that does what users expect

This is the easiest fix in the list and the one most often skipped. The autoComplete attribute tells browsers and assistive tech what kind of data a field expects, which lets the browser fill it in correctly and lets users with cognitive disabilities skip retyping things they have entered a thousand times before.

WCAG 1.3.5 (Identify Input Purpose) requires this for any field that asks for a personal data type defined in the spec — name, email, address, phone, payment fields, and so on. The full list is in the HTML spec, but the common ones:

<input type="text" name="firstName" autoComplete="given-name" />
<input type="text" name="lastName" autoComplete="family-name" />
<input type="email" name="email" autoComplete="email" />
<input type="tel" name="phone" autoComplete="tel" />
<input type="text" name="address" autoComplete="street-address" />
<input type="text" name="postalCode" autoComplete="postal-code" />
<input type="text" name="cardNumber" autoComplete="cc-number" />

For multi-section forms (billing address vs. shipping address), you can scope autocomplete to a section using the section-* prefix and billing / shipping tokens:

<input autoComplete="shipping street-address" />
<input autoComplete="billing street-address" />

If you find yourself fighting browser autofill, the fix is almost always to give the browser more information, not less. autoComplete="off" is honored inconsistently and tends to make things worse, not better.

Focus after submit

The pattern most teams miss entirely is what happens to keyboard focus after the form is submitted. The user clicks "Submit," the form validates, and one of three things happens:

  • The form succeeds and the page navigates somewhere new.
  • The form succeeds and the page shows an inline confirmation.
  • The form fails with validation errors.

In all three cases, the focus has to go somewhere predictable. The default — focus stays on the submit button, which may have just been removed from the DOM — is the worst possible outcome.

For success with navigation, the destination page should set focus to its main heading or its main landmark. Most React routers do not do this for you. If you are using Next.js or React Router, you typically need a small effect that focuses an h1 on route change.

For success with inline confirmation, render the confirmation in a region the user will discover. A live region works, but the more reliable pattern is to render a heading and move focus to it:

const successRef = useRef(null);

useEffect(() => {
  if (submitted) successRef.current?.focus();
}, [submitted]);

return submitted ? (
  <h2 ref={successRef} tabIndex={-1}>
    Thank you. We will email you a receipt.
  </h2>
) : (
  /* the form */
);

The tabIndex={-1} is what makes the heading focusable programmatically without putting it in the tab order. After it has been announced once, the user can continue tabbing forward to whatever comes next.

For failure with errors, focus the first invalid field as shown earlier.

How we test this at inklu

I will not pretend a single automated tool catches all of this. axe-core, which is the engine inklu builds on, will flag missing labels and missing autocomplete, but the focus-after-submit failure is a runtime behavior that needs either a recorded keyboard pass or a custom test. Our proprietary rules layer on top of axe-core catches a number of the React-specific patterns above — unlabeled inputs in JSX, missing aria-describedby linkage, decorative-only required indicators — and our auto-fix PR will rewrite the JSX to add the missing attributes.

What axe and inklu cannot do is the human walkthrough. Pull a keyboard, tab through your form, submit it incomplete, submit it complete, and listen with VoiceOver or NVDA to what happens. The whole walk should take ninety seconds for a typical form. If that ninety seconds reveals anything surprising, it would also have surprised a real user.

What to do next

If you are starting from scratch on a form, use a <label> for every input, give every personal-data field an autoComplete value, render errors with aria-describedby and aria-invalid, and write a deliberate handler for what happens to focus after submit. That is roughly 80% of form accessibility for free.

If you are auditing an existing codebase, the highest-leverage thing to do is run an automated scan to surface the labels and autocomplete failures, then do a keyboard pass on the top three or four forms by traffic. The lawsuit-shaped risk is not in your settings page. It is on signup, login, checkout, and contact.

We built inklu so that the first step — running the scan and getting fix-ready pull requests for the easy violations — does not have to be a multi-week project. A repo scan on a typical React app finishes in a few minutes, the PRs land with the JSX changes already written, and your team gets to spend its time on the work an automated tool cannot do for you.

If you want to see what it finds on your codebase, book a demo at inklu.io or email hello@inklu.io. Pricing is on the pricing page — a Growth plan covers most teams running this on a weekly cadence.