Blog

Part 48 · Accessible Forms in React

Give every box a name, link hints and errors to their boxes, move the focus to an error summary, and tell everyone when the form is busy or done.

In Part 28 we read the text in form boxes. In Part 29 we sent forms with Actions. Those forms worked well with a mouse and a screen. But many people use a form in other ways.

A screen reader is a program that says the words on the page out loud. Some people use it because they can’t see the screen. Others fill in a form with only a keyboard, as in Part 47. A form is accessible when all of these people can fill it in and send it.

Part 46 showed what a screen reader gets from the browser: a second tree next to the page. This part uses that tree for forms. We’ll give every box a name and group radio buttons. We’ll mark boxes that must be filled in, and link hints and errors to their boxes. Then we’ll build an error summary that takes the focus, and tell everyone when the form is busy or done.

Try this first

Read this code. Don’t press Run yet.

export default function App() {
  return (
    <form>
      <p>
        <input placeholder="Your email" />
      </p>
      <p>
        <label>
          Your city <input />
        </label>
      </p>
    </form>
  )
}

Both boxes say what they are for. Make two guesses:

  1. Type your email in the first box. Can you still see what the box is for?
  2. Click the words “Your city”. What happens?

Now press Run and try both.

The words “Your email” go away as soon as you type. Only your email is left. And when you click “Your city”, its box gets the focus, so you can type in it. The first box has a placeholder: grey text that shows only while the box is empty. The second box has a label: text that names the box and stays on the page.

Every box needs a name

Every box needs a name that stays on the page. A program can read it, and a person can click it.

A screen reader doesn’t see the page. It reads the browser’s second tree, called the accessibility tree. In that tree, each box has a role, like textbox, and a name. When the focus moves to a box, the screen reader says its name. Without a name, the tree knows only that the box is a text box. Then the person must guess what to type.

The <label> tag gives a box its name. There are two ways to link a label to its box.

Wrap the box in the label, as the city box did. The label’s text becomes the box’s name.

Point the label at the box’s id. In HTML you write <label for="email">. In JSX it’s htmlFor, as Part 1 explained. The box needs the same id.

An id must be unique on the whole page. A component can be used many times, so don’t write the id by hand. Use useId, as in Part 38. React’s <input> page says the same: “generate such an ID with useId“.

import { useId } from 'react'

function TextField({ label }: { label: string }) {
  const id = useId()
  return (
    <p>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </p>
  )
}

export default function App() {
  return (
    <form>
      <TextField label="First name" />
      <TextField label="Last name" />
    </form>
  )
}

Run it and click “Last name”. The second box gets the focus. Each TextField got its own id, so each label points at its own box.

We put six shapes on a page and read the accessibility tree in Chromium 151. We also ran axe-core 4.14.0 on each one. axe-core is a free tool that checks a page for common accessibility problems.

The box Its name in the tree axe-core
<label htmlFor={id}>Email</label> and <input id={id} /> “Email” no problems
<label>Email <input /></label> “Email” no problems
<input aria-label="Email" /> “Email” no problems
<input placeholder="Email" /> “Email” no problems
<input /> no name “Form elements must have labels”
<label htmlFor="emial"> and <input id="email" /> no name “Form elements must have labels”

The last row has a small spelling mistake in the id. The label shows on the page, but it names nothing. useId makes this mistake hard to make, because you never type the unique part of the id.

aria-label also gives a name. It’s useful when a box has no visible text, like a search box with only an icon next to it. But a visible label helps everyone, so use a <label> when you can.

Why a placeholder is not a label

Look at the placeholder row again. Chromium gave that box the name “Email”, and axe-core found no problem. So is a placeholder fine?

No. The rules for browsers say to use the placeholder as a name only when nothing else names the box. The HTML standard says: “The placeholder attribute should not be used as an alternative to a label.” MDN says the same thing: “The placeholder must not be used instead of a <label>.”

You saw the main reason in “Try this first”. The placeholder goes away when you type. Now the person can’t check what the box was for. W3C’s forms guide adds that placeholder text “is usually displayed with lower color contrast”. Grey text on white is hard to read for many people.

So a placeholder can show an example, like ana@example.com. The label still names the box. And notice that axe-core passed the box with only a placeholder. A tool can’t catch every problem.

Groups: <fieldset> and <legend>

Radio buttons ask one question with several answers. Each radio button has its own label, like “Fast”. But “Fast” alone doesn’t say what the question was.

A <fieldset> groups the boxes that answer one question. Its <legend> is the question.

export default function App() {
  return (
    <form>
      <fieldset>
        <legend>How fast should we send it?</legend>
        <label>
          <input type="radio" name="speed" value="fast" /> Fast
        </label>
        <label>
          <input type="radio" name="speed" value="normal" defaultChecked /> Normal
        </label>
      </fieldset>
    </form>
  )
}

In Chromium’s tree, the <fieldset> became a “group” with the name “How fast should we send it?”. The two radio buttons sat inside it. Then we wrote the question as a plain paragraph, with no <fieldset>. The tree had two radio buttons named “Fast” and “Normal”, and nothing linked them to the question. The axe-core tool found no problem with either shape. It can’t know which text is the question.

W3C’s forms guide says: “Radio button groups should always be grouped using <fieldset>.” It groups related checkboxes the same way.

Boxes that must be filled in

Some boxes must be filled in. Tell everyone, not only people who can see a red star.

The best way to start is to write it in the label: “Name (required)”. MDN’s page on aria-required uses the same words in its own example. Then everyone gets the same text, and a screen reader reads it as part of the name.

Then tell the browser too. There are two ways, and they are not the same:

  • required is an HTML attribute. The browser won’t send the form while the box is empty.
  • aria-required="true" only adds a note to the accessibility tree. MDN says that, like all ARIA, it doesn’t change how the box works.

We measured both in Chromium, Firefox and WebKit:

required aria-required="true"
The tree says “required” (Chromium) yes yes
Pressing Send with the box empty the form is not sent the form is sent
The CSS :invalid rule matches the empty box yes no

So for a real <input>, required is the one that does something. aria-required is for things you build yourself, like a <div> that acts as a checkbox.

Is “(required)” in the label plus required too much? MDN says to leave out aria-required when the label already says “required”. Then a screen reader doesn’t say it twice. The required attribute puts “required” in the tree too, so the same can happen with it. Yet MDN’s own example uses “(required)” with required. Hearing a word twice is a small cost. The visible words help every user.

GOV.UK goes the other way. Its validation page says: “Do not add ‘required’ to your input fields.”

It turns off the browser’s checks. It also asks for research on whether this causes problems for screen reader users. So sources disagree here too.

There is one catch with required. The browser then shows its own error bubble, and you may want your own messages. We’ll come back to that in The browser’s own checks.

Hints: aria-describedby

A hint is extra help next to a box, like “Use 8 or more letters and numbers.” The label stays short. The hint gives the details.

To link the hint to the box, give the hint an id. Then put that id in the box’s aria-describedby. The hint becomes the box’s description. A screen reader usually says the name first, and the description after it.

import { useId } from 'react'

function PasswordField() {
  const id = useId()
  const hintId = id + '-hint'
  return (
    <div>
      <label htmlFor={id}>Password</label>
      <p id={hintId}>Use 8 or more letters and numbers.</p>
      <input id={id} type="password" aria-describedby={hintId} />
    </div>
  )
}

export default function App() {
  return (
    <form>
      <PasswordField />
    </form>
  )
}

React’s useId page links a password hint the same way. It also shows one id from useId as the start of several ids. Here it starts both ids, so they stay unique when the field is used twice. In Chromium’s tree, the box got the name “Password” and the description “Use 8 or more letters and numbers.”

Showing an error on one box

Now the user makes a mistake. A good error does three things:

  1. It says what is wrong in words, not only with a red color. WCAG is the main set of rules for accessible web pages. Its rule 3.3.1 says the error must be “described to the user in text”.
  2. It marks the box as wrong, with aria-invalid="true".
  3. It links the error text to the box, with aria-describedby, so a screen reader reads it with the box.
import { useId, useState } from 'react'

export default function App() {
  const id = useId()
  const [error, setError] = useState('')

  function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault()
    const email = String(new FormData(e.currentTarget).get('email')).trim()
    setError(email.includes('@') ? '' : 'Enter an email address with an @, like ana@example.com')
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor={id}>Email</label>
      <p id={id + '-hint'}>We only use it to send your receipt.</p>
      {error && <p id={id + '-error'}>Error: {error}</p>}
      <input
        id={id}
        name="email"
        type="email"
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? `${id}-hint ${id}-error` : `${id}-hint`}
      />
      <button type="submit">Send</button>
    </form>
  )
}

Type “ana” and click Send. The error appears between the hint and the box. Then fix the email and click Send again. The error goes away.

Some details matter here:

  • noValidate on the form turns off the browser’s own error bubbles. This form shows its own messages instead. The browser’s own checks explains it.
  • aria-describedby can hold more than one id, with spaces between them. With an error, it lists the hint and then the error. We tested this shape on a password box in Chromium. Its description was the hint and then the error, in that order. The error didn’t push the hint out.
  • aria-invalid={error ? true : undefined} leaves the attribute out when there is no error. React writes aria-invalid="true" for true. For false, it writes aria-invalid="false", which also means “not wrong”.
  • The word “Error:” starts the message, so it doesn’t sound like a hint. GOV.UK’s error messages start with a hidden “Error:”, for screen reader users.

What about aria-errormessage?

ARIA also has aria-errormessage, made just for errors. It points at the error text, like aria-describedby does. MDN’s aria-invalid page suggests it for error messages. But support is mixed.

a11ysupport.io tests screen readers with browsers. Each pair below is a screen reader and a browser. NVDA, JAWS and Narrator are screen readers for Windows. VoiceOver comes with Apple’s computers and phones. TalkBack comes with Android phones. Its tests are from 2023 to 2025.

  • These read the error text: JAWS with Chrome or Edge, NVDA with Chrome or Firefox, TalkBack with Chrome, and VoiceOver on an iPhone.
  • These didn’t: JAWS with Firefox, NVDA with Edge, Narrator with Edge, and VoiceOver with Safari on a Mac.

The axe-core tool also flagged our test box, under its rule aria-valid-attr-value. It wanted the error text to be a live region, or also listed in aria-describedby.

GOV.UK’s error messages use aria-describedby. So does this part.

When to show errors

Should an error appear while the user types? When they leave the box? Or when they press Send? To validate a form means to check that its values follow the rules. Expert sources don’t all agree on when to do it.

  • GOV.UK’s design system says: “Do not validate when the user moves away from a field.” Wait until the user tries to send the form. It adds that checking too early “can cause problems – especially for users who type more slowly”.
  • W3C’s forms guide shows all three times. It checks after the form is sent, while the user types, and when the focus leaves a box. Its typing example checks if a user name is free.
  • MDN’s aria-invalid page says: “Do not set aria-invalid="true" on empty required elements until after the user attempts to submit the form.”
  • But MDN’s own aria-invalid example checks each box when the focus leaves it.

A simple rule: validate when the form is sent. Check while the user types only when it clearly helps. GOV.UK’s own example is a box with a limit on its length. It warns as soon as the text is too long. This part checks on submit.

The browser’s own CSS has both kinds of timing. We measured in all three browsers. :invalid matched the empty required boxes as soon as the page loaded, before the user did anything. The newer :user-invalid waited. While the user was still typing in a box, it didn’t match. It matched only after the user changed the box and then left it, or pressed Send. So :user-invalid checks when the user leaves a box. That is the timing GOV.UK advises against.

Moving the focus to the first error

After a failed submit, where is the focus? Still on the Send button. A screen reader user may not know that anything went wrong. W3C’s forms guide has advice for this case: “it is convenient to set the focus to the first <input> element that contains an error.”

To move the focus, we need the real box on the page. That is a ref, as in Part 14. But there is a timing problem. When handleSubmit runs, React hasn’t put the error on the page yet. A set function only asks for a new render (Part 4). If we focus the box now, it doesn’t have its error yet.

flushSync from react-dom fixes this. It makes React update the page at once, right after the code inside it runs. React’s docs page on refs uses it for the same kind of job. So inside flushSync we set the errors. On the next line, the errors are on the page, and we can move the focus.

import { useId, useRef, useState } from 'react'
import { flushSync } from 'react-dom'

export default function App() {
  const id = useId()
  const nameRef = useRef<HTMLInputElement>(null)
  const emailRef = useRef<HTMLInputElement>(null)
  const [errors, setErrors] = useState({ name: '', email: '' })

  function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    const found = {
      name: String(data.get('name')).trim() === '' ? 'Enter your name' : '',
      email: String(data.get('email')).includes('@') ? '' : 'Enter an email address with an @',
    }
    flushSync(() => setErrors(found))
    if (found.name) nameRef.current?.focus()
    else if (found.email) emailRef.current?.focus()
    console.log('Focus is on:', document.activeElement?.getAttribute('name'))
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor={id + '-name'}>Name</label>
        {errors.name && <p id={id + '-name-error'}>Error: {errors.name}</p>}
        <input ref={nameRef} id={id + '-name'} name="name"
          aria-invalid={errors.name ? true : undefined}
          aria-describedby={errors.name ? id + '-name-error' : undefined} />
      </div>
      <div>
        <label htmlFor={id + '-email'}>Email</label>
        {errors.email && <p id={id + '-email-error'}>Error: {errors.email}</p>}
        <input ref={emailRef} id={id + '-email'} name="email" type="email"
          aria-invalid={errors.email ? true : undefined}
          aria-describedby={errors.email ? id + '-email-error' : undefined} />
      </div>
      <button type="submit">Send</button>
    </form>
  )
}
Focus is on: email

Type “Ana” as the name, leave the email empty, and click Send. The focus jumps to the email box, and the Console says Focus is on: email. The line is printed once, even in Strict Mode. Strict Mode runs components twice (Part 2), but an event handler is not a component. React calls it once for each submit.

We checked the timing in our test. With flushSync, the email box already had aria-invalid="true" when it got the focus. Without flushSync, it got the focus first and its error a moment later.

An error summary at the top

A long form can have many errors. GOV.UK’s design system puts an error summary at the top of the form. It’s a box with the title “There is a problem” and a list of every error. Its page says you must:

  • “move keyboard focus to the error summary”
  • “include the heading ‘There is a problem’”
  • “link to each of the answers that have validation errors”

It also says to word each error the same in the summary and next to its box.

Here is the whole flow. The focus is the thing to watch.

1. You press Sign up. The focus is on the button. 2. The code checks the boxes. Two of them are wrong. 3. The summary appears at the top. The focus moves to it. 4. You follow a link. The focus jumps to the email box. There is a problemEnter your nameEnter your email address Name Email Error: Enter your name Error: Enter your email address Sign up document.activeElement <button>

Submit, errors, summary, link: follow the focus. Press play, or step through it.

  1. You press Sign up. The focus is on the button.
  2. The code checks the boxes. Two of them are wrong.
  3. The summary appears at the top, and the focus moves to it. Its heading says “There is a problem”.
  4. You follow the link “Enter your email address”. The focus jumps to the email box, with its label and error next to it.

Here it is in code. TextField is the field from before, now with an error and a ref. In React 19, ref is a normal prop, as Part 14 showed.

import { useId, useRef, useState, type Ref } from 'react'
import { flushSync } from 'react-dom'

type FieldProps = {
  ref: Ref<HTMLInputElement>
  id: string
  name: string
  label: string
  type?: string
  autoComplete?: string
  error?: string
}

function TextField({ ref, id, name, label, type = 'text', autoComplete, error }: FieldProps) {
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      {error && <p id={id + '-error'}>Error: {error}</p>}
      <input ref={ref} id={id} name={name} type={type} autoComplete={autoComplete}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? id + '-error' : undefined} />
    </div>
  )
}

type Field = 'name' | 'email'
type Errors = Partial<Record<Field, string>>

// GOV.UK's way: scroll the label into view, then focus the box without scrolling again.
function focusField(input: HTMLInputElement | null) {
  input?.labels?.[0]?.scrollIntoView()
  input?.focus({ preventScroll: true })
}

function check(data: FormData): Errors {
  const errors: Errors = {}
  const name = String(data.get('name')).trim()
  const email = String(data.get('email')).trim()
  if (name === '') errors.name = 'Enter your name'
  if (email === '') errors.email = 'Enter your email address'
  else if (!email.includes('@')) errors.email = 'Enter an email address with an @, like ana@example.com'
  return errors
}

export default function App() {
  const id = useId()
  const summaryRef = useRef<HTMLDivElement>(null)
  const fieldRefs = { name: useRef<HTMLInputElement>(null), email: useRef<HTMLInputElement>(null) }
  const [errors, setErrors] = useState<Errors>({})
  const [message, setMessage] = useState('')
  const wrong = Object.keys(errors) as Field[]

  function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault()
    const data = new FormData(e.currentTarget)
    const found = check(data)
    const ok = Object.keys(found).length === 0
    flushSync(() => {
      setErrors(found)
      setMessage(ok ? `Thank you, ${data.get('name')}. You are signed up.` : '')
    })
    if (!ok) summaryRef.current?.focus()
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      {wrong.length > 0 && (
        <div ref={summaryRef} tabIndex={-1}>
          <h2>There is a problem</h2>
          <ul>
            {wrong.map(field => (
              <li key={field}>
                <a
                  href={`#${id}-${field}`}
                  onClick={e => {
                    e.preventDefault()
                    focusField(fieldRefs[field].current)
                  }}
                >
                  {errors[field]}
                </a>
              </li>
            ))}
          </ul>
        </div>
      )}
      <TextField ref={fieldRefs.name} id={id + '-name'} name="name" label="Name"
        autoComplete="name" error={errors.name} />
      <TextField ref={fieldRefs.email} id={id + '-email'} name="email" label="Email"
        type="email" autoComplete="email" error={errors.email} />
      <button type="submit">Sign up</button>
      <p role="status">{message}</p>
    </form>
  )
}

Run it and click Sign up with both boxes empty. The summary appears, and it has the focus. Press Tab twice to reach the second link, then press Enter. The focus jumps to the email box. Fill in both boxes and sign up again. The summary goes away, and a thank-you line appears.

Look at the parts one by one:

  • tabIndex={-1} on the summary. A plain <div> can’t take the focus. tabIndex={-1} lets code focus it, but Tab doesn’t stop on it (Part 47). GOV.UK’s script does it a little differently. It adds tabindex="-1" only if the summary has none, and takes it away when the focus leaves. Our summary keeps it, to keep the code short.
  • Moving the focus is what makes the summary heard. GOV.UK’s script says it takes the focus “for accessible announcement”. Our summary has no role="alert", and that is on purpose. MDN’s alert page says that adding an alert that already has its text “generally does not lead to an announcement”. MDN’s live regions page says alerts are read “in most cases”, even when they are added. The two MDN pages disagree, so we don’t count on the alert.
  • Each link’s onClick. It stops the browser’s own jump and calls focusField. That is GOV.UK’s way. It scrolls the label into view, and then focuses the box with preventScroll: true. We tried this on a plain long page in all three browsers. The label ended up at the top of the window, with the box just under it. In the result box here, we pressed Enter on the second link. Chromium and Firefox scrolled the label to the top edge of the window. WebKit didn’t scroll, but the label and the box were already in view.
  • Why not a plain link? We tried a plain page, not the result box. There, a link to #email with no script did move the focus to the box, in all three browsers. But the browser scrolled the box to the very top of the window. The label ended up just above it, out of view. GOV.UK’s script says the same: “the label or legend will be off the top of the screen”. In the result box here, a plain link to #... would load this whole lesson page into the box. Its address is worked out from the lesson page. Part 47 saw that with a skip link.
  • fieldRefs. It holds one ref for each box, so a link can reach its box by the field’s name. Each useRef call is always made, in the same order, so this follows the rules of Hooks.
  • The same words twice. The summary and the boxes show the same error text, as GOV.UK asks. GOV.UK also says to show the summary even when there is only one error. Ours does.

GOV.UK’s pages work a little differently from ours. Their form goes to the server, and the server sends back a new page with the summary. Their script focuses the summary when that page loads. In React, the check runs in the browser, so we move the focus in the submit handler. Your server must still check everything again. GOV.UK says: “You’ll always need to carry out server side validation, even if you use client side validation.”

GOV.UK’s validation page adds one more step for a real page. Put “Error: ” at the start of the page’s <title>, “so screen readers read it out as soon as possible”.

Telling everyone what happened: live regions

Some messages appear without the focus moving: “Signing you up…”, “Thank you. You are signed up.” A sighted user sees them. A screen reader user hears nothing, unless the message is in a live region.

A live region is a part of the page that the screen reader watches. When its text changes, the screen reader reads the new text. Part 46 explained live regions. A form uses both of their roles:

Role Chromium’s tree said Use it for
role="status" live=polite, atomic=true news that can wait, like “Signing you up…”
role="alert" live=assertive, atomic=true something urgent that the user must hear at once

Atomic means the screen reader reads the whole region again, not only the part that changed.

Two rules from MDN matter most in a form:

  • Put the live region on the page first, then change its text. Our <p role="status"> is always on the page, even when message is empty. MDN says: “Establish the live region before updating its content.”
  • Use alerts only a few times. MDN says the alert role “must be used sparingly”, which means rarely. A form with three errors should not make three alerts at once.

The browser’s own checks

Browsers can check a form on their own. This is called constraint validation. A constraint is a rule, like required or type="email". If a rule is broken, the browser doesn’t send the form. It shows a small message bubble next to the first wrong box.

import { useState } from 'react'

export default function App() {
  const [sent, setSent] = useState(false)

  function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault()
    setSent(true)
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>
        <label>
          Name (required) <input name="name" required />
        </label>
      </p>
      <p>
        <label>
          Email (required) <input name="email" type="email" required />
        </label>
      </p>
      <button type="submit">Send</button>
      <p>{sent ? 'handleSubmit ran.' : 'handleSubmit has not run.'}</p>
    </form>
  )
}

Run it. Type “ana” in the email box, leave the name empty, and click Send. A bubble says the name box must be filled in, and the focus moves to it. handleSubmit doesn’t run, so the last line doesn’t change. Fill in both boxes correctly, and it runs.

We measured what happened in each browser, with this kind of form:

  • While the user typed or left a box, no invalid event fired. The checks that block the form ran only when the user pressed Send.
  • On Send, the browser fired an invalid event on each wrong box. Then it moved the focus to the first one. The submit event didn’t fire.
  • Each browser wrote its own message. Your code can read it from the box’s validationMessage.
Chromium 151 Firefox 153 WebKit 26.5
Empty required box Please fill out this field. Please fill out this field. Fill out this field
“ana” in an email box Please include an ‘@’ in the email address. ‘ana’ is missing an ‘@’. Please enter an email address. Enter an email address

The browser’s checks cost almost no code. You can change the words with setCustomValidity(). But MDN says the messages “cannot be styled”. And you can’t choose where the bubble shows, or how long it stays. GOV.UK’s design system tells its teams to turn it off with novalidate. They say its messages “cannot be made consistent” with their own error components. That is why our examples use noValidate.

noValidate turns off only the bubble and the blocking. The browser still checks the rules. In a noValidate form, required still made the box “required” in Chromium’s tree. And in all three browsers, the box’s validity object still knew what was wrong. For the empty name it had valueMissing: true. For “ana” in the email box it had typeMismatch: true. Your own code can read input.validity and write your own message. This is the constraint validation API: validity, validationMessage, checkValidity(), and setCustomValidity() for your own rules.

Help for phones: autoComplete, type and inputMode

Typing on a phone takes time. Three attributes help.

autoComplete tells the browser what a box is for, so it can fill it in from what it saved before. In JSX it’s autoComplete, with a capital C. WCAG’s rule 1.3.5 asks for this on boxes about the user. The purpose of each box must be something a program can read. Some values from MDN’s list:

What the box asks for autoComplete
Full name name
Email address email
Phone number tel
Postal code (the code for your area in an address) postal-code
A new password, when signing up new-password
The password, when signing in current-password
A code sent by text message one-time-code

type changes the keyboard on many phones and adds checks. type="email" checks for an @, as we measured. type="tel" shows a keyboard for phone numbers, MDN says. But it adds no checks, because phone numbers are written so many ways around the world.

inputMode changes only the keyboard. It adds no checks. inputMode="numeric" asks for a keyboard with the numbers 0 to 9. MDN warns against type="number" for things like postal codes and card numbers. They are made of numbers, but they aren’t amounts. For a card number, use a text box with inputMode="numeric". Many countries’ postal codes have letters too, so keep a postal code box a plain text box.

export default function App() {
  return (
    <form>
      <p>
        <label>
          Phone <input name="phone" type="tel" autoComplete="tel" />
        </label>
      </p>
      <p>
        <label>
          Postal code <input name="postcode" autoComplete="postal-code" />
        </label>
      </p>
      <p>
        <label>
          Code from the text message <input name="code" inputMode="numeric" autoComplete="one-time-code" />
        </label>
      </p>
    </form>
  )
}

React writes them as autocomplete and inputmode, the HTML names. We can’t press keys on a phone keyboard in this page. The keyboard part comes from MDN, not from our tests.

The Send button: disabled or aria-disabled?

In Part 29, the button got disabled={pending} while the form was busy. That stops a second click. But is it good for accessibility? Sources disagree, so let’s start with measurements. We tried both in all three browsers:

disabled aria-disabled="true"
Tab stops on the button no yes
A mouse click sends the form no yes
Chromium’s tree says “disabled” yes yes
The focus, when the focused button becomes disabled falls back to <body> stays on the button

The last row is the problem. A keyboard user presses Enter on Send. The button becomes disabled, and document.activeElement becomes <body>. Nothing on the page has the focus now. In our test, the next Tab went on to the control after the button. But until then, the user’s place is gone.

What the sources say:

  • GOV.UK says disabled buttons “have poor contrast” and are not clear to some users. Its advice: “avoid them if possible”.
  • MDN’s aria-disabled page gives “submitting a form” as a case for aria-disabled: the button stays in the Tab order. But MDN also says you still need JavaScript to stop the button from working.
  • W3C’s ARIA guide says browsers take disabled boxes out of the Tab order. It gives aria-disabled for when a disabled control should stay focusable.

So if you use aria-disabled, you must block the second click yourself. We’ll do that in the next example.

React 19 form Actions, with a busy message

Now let’s put it together with Part 29‘s tools. The form uses an Action and useActionState. A child component uses useFormStatus to know when the form is busy.

import { useActionState, useEffect, useId, useRef } from 'react'
import { useFormStatus } from 'react-dom'

type SignUpState = { error: string; email: string; message: string }

// A fake server. It takes 800 ms to save an email.
function saveEmail(email: string): Promise<void> {
  console.log('Server: saving', email)
  return new Promise(resolve => {
    setTimeout(resolve, 800)
  })
}

async function signUp(prevState: SignUpState, formData: FormData): Promise<SignUpState> {
  const email = String(formData.get('email')).trim()
  if (!email.includes('@')) {
    return {
      error: 'Enter an email address with an @, like ana@example.com',
      email,
      message: 'You are not signed up yet. Check the email box.',
    }
  }
  await saveEmail(email)
  return { error: '', email: '', message: `Thank you. We sent a message to ${email}.` }
}

function SubmitArea({ message }: { message: string }) {
  const { pending } = useFormStatus()
  return (
    <>
      <button
        type="submit"
        aria-disabled={pending}
        style={pending ? { opacity: 0.5 } : undefined}
        onClick={e => {
          if (pending) e.preventDefault()
        }}
      >
        Sign up
      </button>
      <p role="status">{pending ? 'Signing you up...' : message}</p>
    </>
  )
}

export default function App() {
  const id = useId()
  const inputRef = useRef<HTMLInputElement>(null)
  const [state, signUpAction] = useActionState(signUp, { error: '', email: '', message: '' })

  useEffect(() => {
    if (state.error) inputRef.current?.focus()
  }, [state])

  return (
    <form action={signUpAction} noValidate>
      <label htmlFor={id}>Email</label>
      {state.error && <p id={id + '-error'}>Error: {state.error}</p>}
      <input
        ref={inputRef}
        id={id}
        name="email"
        type="email"
        autoComplete="email"
        defaultValue={state.email}
        aria-invalid={state.error ? true : undefined}
        aria-describedby={state.error ? id + '-error' : undefined}
      />
      <SubmitArea message={state.message} />
    </form>
  )
}
Server: saving ana@example.com

Run it and try this:

  1. Type “ana” and click Sign up. The error appears next to the box, and the status line says “You are not signed up yet. Check the email box.” The focus goes back to the box, which still says “ana”.
  2. Fix the email and click Sign up. The status line says “Signing you up…”, and the button gets lighter. The old error stays until the Action returns. Click the button again while it says “Signing you up…”. Nothing more happens.
  3. After 800 ms, the status line says “Thank you. We sent a message to ana@example.com.”, and the box is empty.

The Console shows “Server: saving” only once, even though we clicked twice.

How each piece works:

  • The status line. <p role="status"> is always on the page. Its text changes to “Signing you up…”, and then to the result. So a screen reader can read each change.
  • Why the error goes in the status line too. Press Enter in the email box to send the form. The focus is already in the box, so focus() changes nothing. A focus move can’t tell the user anything here. The status line still changes, so the news still reaches the user. useFormStatus must be in a child of the form, as Part 29 explained. That’s why the status line is in SubmitArea.
  • The button. aria-disabled={pending} tells the screen reader that the button is not available right now, but keeps it in the Tab order. The status line says why. aria-disabled adds no look of its own, so MDN suggests styling it, for example with opacity: 0.5. The onClick blocks the second click: e.preventDefault() on a submit button’s click stops the form from being sent.
  • Keeping the text. After an Action, React sets the form’s boxes back to their defaultValue (Part 29). The Action returns the email in its state, and defaultValue={state.email} puts it back.
  • The focus. An Action has no event handler to call focus() from, and no place for flushSync. So the usual way is an Effect. It moves the focus after React shows the error (Part 11). In Strict Mode, React runs the Effect twice when the form first appears. Both times state.error is empty, so nothing moves.

Checking a bad form with axe-core

Here is a form like the kata’s “before” example, with one more mistake added. It has a placeholder instead of a label. Its error is only a red paragraph, not linked to the box.

import { useState } from 'react'

export default function App() {
  const [email, setEmail] = useState('')
  const [sent, setSent] = useState(false)
  const wrong = sent && !email.includes('@')

  function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault()
    setSent(true)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} />
      {wrong && <p style={{ color: 'red' }}>That doesn't look like an email</p>}
      <div onClick={() => setSent(true)} style={{ background: '#1976d2', color: 'white', padding: 8 }}>
        Sign up
      </div>
    </form>
  )
}

It has three problems:

  • The placeholder is the only name.
  • The error isn’t linked to its box.
  • A <div> pretends to be a button. Tab can’t reach it, and Enter can’t press it.

We ran axe-core 4.14.0 on this form in all three browsers, after clicking “Sign up”. It found one problem: “Elements must meet minimum color contrast ratio thresholds”. That is the red text. Plain red on white is too light to read easily. We also pressed Tab in the box. The focus left the form and skipped the “Sign up” <div>.

Then we ran axe-core on the error summary form above. It found no problems before a submit, and none after a submit with two errors.

So axe-core caught the color, but none of the three problems in the list. It didn’t flag the <div> with a click handler, and a placeholder counts as a name. Automatic tools find some problems, not all. Also try your form with only a keyboard, and with a screen reader. Part 50 adds automatic checks to tests.

Common mistakes

A placeholder as the only name

const wrong = <input placeholder="Email" />

The text goes away when the user types, and it’s hard to read. Use a label, and keep the placeholder for an example: <label>Email <input placeholder="ana@example.com" /></label>.

Writing the same id in a reusable component

function EmailField() {
  return (
    <p>
      <label htmlFor="email">Email</label>
      <input id="email" />
    </p>
  )
}

export default function App() {
  return (
    <form>
      <EmailField />
      <EmailField />
    </form>
  )
}

Two copies make two boxes with id="email". We clicked the second label in all three browsers. The focus went to the first box. The browser links a label to the first element with that id on the page. Use useId.

Replacing the hint with the error

function describedBy(id: string, error: string) {
  return error ? id + '-error' : id + '-hint'
}

The kata does this. When the error appears, the box loses its hint, and the user loses the help they need to fix it. List both ids, the hint’s first, as the error example above does.

role="alert" on every error message

When the form has three errors, three alerts appear at the same time. MDN says the alert role “must be used sparingly”. And each alert is added to the page with its text already inside. MDN’s alert page says that “generally does not lead to an announcement”. Move the focus to one error summary instead, and link each error to its box with aria-describedby.

disabled={pending} on a button that has the focus

We measured it: the focus falls back to <body>. Use aria-disabled and block the click yourself, as in the Actions example.

Marking a box wrong before the user did anything

const wrong = <input aria-label="Name" required aria-invalid={true} />

The empty box is marked as wrong before the user has typed a thing. MDN: don’t set aria-invalid="true" on empty required boxes “until after the user attempts to submit the form”. Set it only after a check.

Practice

Press Edit on the examples above and try these.

  1. In the error summary example, add a third box: “Phone (optional)”, with type="tel" and autoComplete="tel". Don’t add a check for it. Submit with all three boxes empty. How many links are in the summary?
  2. In the “first error” example, add console.log('handleSubmit') as the first line of handleSubmit. Click Send once, with both boxes empty. How many lines does the Console show?
  3. Change the error summary example so the focus goes to the first wrong box, not to the summary. Keep the summary on the page.
  4. Fix the bad form from the axe-core section. Give the box a label, link the error to it, and use a real <button>. After a failed submit, move the focus to the box.
Answers
  1. Two links: one for the name and one for the email. The phone box has no check, so it never gets an error. Add it with <TextField ref={fieldRefs.phone} id={id + '-phone'} name="phone" label="Phone (optional)" type="tel" autoComplete="tel" />, and add phone: useRef<HTMLInputElement>(null) to fieldRefs.
  2. Two lines: handleSubmit, then Focus is on: name. An event handler runs once for each click, even in Strict Mode.
  3. Replace summaryRef.current?.focus() with focusField(fieldRefs[Object.keys(found)[0] as Field].current). The summary still appears. Now the user lands on the first wrong box, and its error is linked to it. This is W3C’s advice. GOV.UK says the opposite: move the focus to the summary.
  4. For example:
import { useId, useRef, useState } from 'react'
import { flushSync } from 'react-dom'

export default function App() {
  const id = useId()
  const inputRef = useRef<HTMLInputElement>(null)
  const [error, setError] = useState('')

  function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
    e.preventDefault()
    const email = String(new FormData(e.currentTarget).get('email'))
    const message = email.includes('@') ? '' : 'Enter an email address with an @'
    flushSync(() => setError(message))
    if (message) inputRef.current?.focus()
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor={id}>Email</label>
      {error && <p id={id + '-error'}>Error: {error}</p>}
      <input ref={inputRef} id={id} name="email" type="email" placeholder="ana@example.com"
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? id + '-error' : undefined} />
      <button type="submit">Sign up</button>
    </form>
  )
}

Interview questions

Try to answer each one out loud before you open the answer.

Why isn’t a placeholder enough as a label?

It goes away when the user types, so they can’t check what the box was for. It’s usually low-contrast grey text. And the HTML standard says it “should not be used as an alternative to a label”. A strong answer adds a measured detail. Chromium does use a placeholder as the box’s name when nothing else names it, and axe-core 4.14.0 doesn’t flag it. So that tool won’t catch this mistake.

How do you link a label to its input in React?

Wrap the input in the <label>, or give the label htmlFor and the input the same id. In a reusable component, make the id with useId, so two copies don’t share one. A strong answer says why that matters. With a repeated id, the second label focuses the first box. And it says not to use useId for list keys.

What is the difference between required and aria-required?

Both make the accessibility tree say “required”. Only required changes what the browser does. It blocks the submit, shows a bubble, and makes :invalid match. aria-required changes nothing but the tree. Use required on real inputs, and aria-required on controls you build from <div>s. A strong answer adds that noValidate turns off the blocking but keeps the “required” in the tree.

How do you make a field’s error accessible?

Show the error as text, not only as a red border. Set aria-invalid="true" on the box. Give the error an id and list it in the box’s aria-describedby, after any hint. Show the error after the user tries to submit, not on page load. Then move the focus: to the first wrong box, or to an error summary with links to each box. A strong answer names aria-errormessage and why many teams still use aria-describedby: screen reader support is mixed.

You set an error in state and then call focus(). Why can the box get the focus before its error is on the page?

A set function only asks React to render later. When the next line runs, the page hasn’t changed yet. Wrap the set call in flushSync to make React update the page first. Or move the focus in an Effect that runs after the update. That’s the usual choice in a form Action. A ref callback is another. A strong answer quotes React’s docs: “Using flushSync is uncommon and can hurt the performance of your app.” Use it for small cases like this one.

Should a Send button be disabled while the form is busy?

It depends, and sources disagree. disabled blocks clicks for you. But it takes the button out of the Tab order. And if the button had the focus, the focus falls to <body>. GOV.UK says to avoid disabled buttons. aria-disabled="true" keeps the button focusable and tells screen readers it’s not working now. But it blocks nothing, so your code must ignore the click. A strong answer gives the measured focus loss, and shows the onClick guard. It also says the server must be safe when the same form comes twice. GOV.UK says to “think about the issue server-side”, and Part 29 says the same.

What is the difference between role="alert" and role="status"?

Both are live regions: a screen reader reads their new text when it changes. status is polite. It waits until the user stops. alert is assertive. It may stop what the screen reader is saying. Use status for “Saved” or “Sending…”, and alert for urgent errors, and only rarely. A strong answer adds that the region should be on the page before its text changes. For role="alert", MDN’s pages disagree. One says an alert added with its text is usually read. The other says it “generally does not lead to an announcement”. So keep the region on the page, or move the focus instead.

Sources

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.