Forms are where accessibility breaks most often and most catastrophically. A broken ARIA role or a missing label doesn't just cause friction — it can make a form completely unusable with a screen reader. I've spent hours trying to figure out why a submit button was dimmed and unclickable, reading back through every field trying to find the missing required indicator that was never announced to me. That experience is what this article is about.

I've been a JAWS user for over 25 years. I've filled out thousands of forms — government portals, medical intake forms, job applications, checkout flows, banking dashboards. The patterns that fail are not obscure edge cases. They're the same five or six mistakes, made over and over again, by teams who tested visually and called it done. This is my attempt to write down what every field in every form actually needs — from the perspective of someone who depends on these properties to function.

The Label — Non-Negotiable

Every input needs a programmatically associated label. Not a placeholder. Not a <div> floating nearby. A label that a screen reader can find and announce when the field receives focus. There are three correct ways to do this, and the choice depends on your design constraints.

The correct way: <label for>

The for attribute links a visible label element to an input by matching the input's id. This is the best option in almost every situation. The label is visible, it's semantic HTML, and it creates a click target that activates the field — which helps users with motor impairments.

<!-- Correct: label associated with input via matching for/id -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" autocomplete="email" />

When JAWS lands on that input, it announces: "Email address, edit text." That's what your users need. If the label is missing, they hear: "Edit text." Useless.

When there's no visible label: aria-label

Sometimes design calls for a search field with no visible label text — just a magnifying glass icon and a placeholder. In that case, aria-label provides the accessible name directly on the element. The text you write goes into the accessibility tree and is announced by the screen reader.

<!-- aria-label for when no visible label is possible -->
<input
  type="search"
  aria-label="Search the site"
  placeholder="Search…"
/>

Use aria-label sparingly. If a sighted user can see label-like text on screen, you should be connecting to that text programmatically rather than duplicating it in an attribute.

When label text exists elsewhere in the DOM: aria-labelledby

aria-labelledby points to one or more existing elements by their IDs and concatenates their text to form the accessible name. It's useful for complex layouts where the label-equivalent text lives inside a heading, table header, or multi-part description.

<!-- aria-labelledby composing name from multiple elements -->
<h2 id="billing-section">Billing Address</h2>

<label id="street-label" for="street">Street</label>
<input
  type="text"
  id="street"
  aria-labelledby="billing-section street-label"
/>
<!-- Announced as: "Billing Address Street, edit text" -->

The critical thing sighted users don't always understand about labeling: the visual proximity of text to a field means nothing to a screen reader. What matters is the programmatic relationship. A design that looks perfectly labeled to a sighted user can be a completely unlabeled field to me.

Required Fields — Announce It, Don't Just Color It

The red asterisk means nothing to a screen reader. I don't see the asterisk. I don't see the color. If your only required field indicator is a red star next to the label text, you have failed every user who can't see color — not just screen reader users. That's a WCAG 1.4.1 failure on top of a forms accessibility failure.

Use the required attribute

The HTML5 required attribute is announced as "required" by most modern screen readers when the user focuses the field. JAWS says: "Email address, required, edit text." It also triggers native browser validation, which is useful but can be overridden with JavaScript validation.

Belt-and-suspenders: aria-required="true"

In ARIA terms, aria-required="true" is explicit. Some older assistive technologies handle it better than the HTML attribute alone. Using both is belt-and-suspenders and I recommend it for any field that absolutely must be filled in.

<!-- Required field with all correct attributes -->
<label for="full-name">
  Full name
  <span aria-hidden="true"> *</span>
  <span class="sr-only">(required)</span>
</label>
<input
  type="text"
  id="full-name"
  name="full-name"
  required
  aria-required="true"
  autocomplete="name"
/>

Notice: the asterisk is marked aria-hidden="true" so the screen reader doesn't read the symbol out loud (some read it as "asterisk", some skip it, some announce it differently — all inconsistent). The explicit "(required)" text in the .sr-only span is what gets announced, clean and predictable. Never use color alone to indicate required state.

I once spent 20 minutes trying to submit a job application. Every time I hit Submit, nothing happened. The form had a required field indicator that was shown as a red underline — only visible to sighted users — and no announcement on focus, no error message announced to the screen reader. I had to navigate field by field through the entire form, listening for anything that might be wrong. The culprit turned out to be an empty dropdown I'd skipped. The red underline was there the whole time. I couldn't see it.

Error Messages — The Most Broken Thing in Forms

If required fields are the most commonly wrong, error messages are the most broken. A visible red error message that appears after form submission is invisible to a screen reader unless you've done specific work to announce it. Most teams haven't.

Link input to error with aria-describedby

aria-describedby supplements the accessible name with additional descriptive text. When an error message exists, point the input at it. The description is announced after the label — usually on focus, sometimes after a brief pause depending on the screen reader.

Mark validation state with aria-invalid

When a field fails validation, set aria-invalid="true" on the input. JAWS announces this as "invalid entry." Combined with the error message linked via aria-describedby, the user gets: "Email address, invalid entry, required, edit text — Please enter a valid email address." That's a complete, actionable announcement.

Inject errors into a live region so they announce on validation

If you're using client-side validation and injecting error messages dynamically after the user submits or blurs a field, the error container needs to be an ARIA live region. Use aria-live="polite" for non-urgent messages, or role="alert" (which implies aria-live="assertive") for errors that need immediate announcement.

<!-- Complete accessible error announcement pattern -->
<label for="user-email">Email address</label>
<input
  type="email"
  id="user-email"
  name="email"
  required
  aria-required="true"
  aria-describedby="email-error"
  aria-invalid="false"
  autocomplete="email"
/>
<!-- Error container: use role="alert" so errors announce on inject -->
<span
  id="email-error"
  role="alert"
  aria-live="assertive"
  style="color: #ff6b6b; font-size: 0.875rem;"
>
  <!-- Empty until validation runs; JS injects error text here -->
</span>

<!-- When validation fails, JS does two things: -->
<!-- 1. Set aria-invalid="true" on the input -->
<!-- 2. Inject error text into the span -->
<script>
  var input = document.getElementById('user-email');
  var errorEl = document.getElementById('email-error');

  input.addEventListener('blur', function () {
    if (!input.validity.valid) {
      input.setAttribute('aria-invalid', 'true');
      errorEl.textContent = 'Please enter a valid email address.';
    } else {
      input.setAttribute('aria-invalid', 'false');
      errorEl.textContent = '';
    }
  });
</script>

The submit button dimming issue I mentioned in the intro: this typically happens when a developer disables the submit button until all required fields are filled, but the disabled state isn't announced to the screen reader. Use aria-disabled="true" combined with visual disabled styling if you need to communicate the state — but better still, keep the button enabled and let validation announce the specific errors on submit. A disabled button with no explanation is a dead end.

State Changes — Tell the Screen Reader

Custom UI components — dropdowns, accordions, custom checkboxes, toggle switches — don't inherit state announcements from native HTML the way <select> or <input type="checkbox"> do. If you've built a custom component, you're responsible for managing every state transition explicitly.

Key ARIA state attributes

<!-- Custom select with proper ARIA state management -->
<div class="custom-select">

  <!-- Trigger button -->
  <button
    id="select-trigger"
    type="button"
    role="combobox"
    aria-haspopup="listbox"
    aria-expanded="false"
    aria-controls="select-listbox"
    aria-labelledby="country-label select-trigger"
  >
    Select a country
    <svg aria-hidden="true" focusable="false">...</svg>
  </button>

  <!-- Options list -->
  <ul
    id="select-listbox"
    role="listbox"
    aria-labelledby="country-label"
    hidden
  >
    <li role="option" aria-selected="false" tabindex="-1">Canada</li>
    <li role="option" aria-selected="false" tabindex="-1">Mexico</li>
    <li role="option" aria-selected="true" tabindex="0">United States</li>
  </ul>
</div>

<!-- On open: set aria-expanded="true", remove hidden from list -->
<!-- On select: set aria-selected="true" on chosen option,
     false on all others, update trigger label, close list -->

The rule I follow: every state that changes visually must also change in the accessibility tree. If a sighted user can see a dropdown is open, a screen reader user must hear "expanded" when they focus the trigger.

Grouping Related Fields

Radio buttons and checkboxes rarely stand alone. They belong to a group — "What is your preferred contact method?" with three options below it. Without proper grouping, a screen reader user landing on the first radio button hears only the option label: "Phone, radio button, 1 of 3." They have no idea what question that radio button is answering.

<fieldset> and <legend>

The <fieldset> element groups related inputs. The <legend> provides the group label. Screen readers announce the legend text with each option inside the group. JAWS reads: "Preferred contact method group — Phone, radio button, 1 of 3." That's the complete information the user needs.

<!-- Correct radio group with fieldset/legend -->
<fieldset>
  <legend>Preferred contact method</legend>

  <div class="radio-option">
    <input
      type="radio"
      id="contact-phone"
      name="contact-method"
      value="phone"
    />
    <label for="contact-phone">Phone</label>
  </div>

  <div class="radio-option">
    <input
      type="radio"
      id="contact-email"
      name="contact-method"
      value="email"
    />
    <label for="contact-email">Email</label>
  </div>

  <div class="radio-option">
    <input
      type="radio"
      id="contact-text"
      name="contact-method"
      value="text"
    />
    <label for="contact-text">Text message</label>
  </div>
</fieldset>

Fieldsets can and should be styled. The default browser rendering has a border and visual grouping that some designers don't like — that's fine, override it with CSS. What you cannot do is replace the semantic grouping with a non-semantic alternative like role="group" on a <div> without careful testing, because browser and screen reader support for that pattern is inconsistent. Stick with <fieldset> and <legend> for radio and checkbox groups. It's the right tool, and it works.

Grouping also matters for address fields, date pickers split across three dropdowns, and card payment fields with separate inputs for number, expiry, and CVV. If there's a shared context for multiple inputs, wrap them in a fieldset. Your users will thank you — and I mean that literally, because I have sent thank-you emails to developers who got this right.

Test It Yourself — With Eyes Closed

Here's my closing advice, and I mean this as directly as I can put it: test your forms with a real screen reader before shipping. Not Lighthouse. Not axe. Not a browser extension that scans for missing attributes. A real screen reader, with the monitor off or your eyes closed, tabbing through every field.

NVDA is free. JAWS has a 40-minute demo mode. VoiceOver is built into every Mac. There is no excuse for skipping this step.

Tab through your entire form. Can you identify what every field is asking for? Can you tell which ones are required? If you hit Submit and there are errors, do they announce? Can you find them and fix them? Can you get to the next step?

If you can complete the form with your eyes closed, you've done something most teams haven't. If you can't — if something trips you up, disorients you, or leaves you guessing — that's the fix. That's the bug. Ship that fix before the form goes live. Because the users who depend on a screen reader don't have the option to "just use the mouse instead." That form is the only path they have.

Need your forms audited by a real screen reader user?

Not automated scanning — manual JAWS and NVDA testing with detailed remediation guidance your team can act on immediately.

Schedule a Free Consultation