Screen Reader Support
Ensure the app is usable with screen readers (NVDA, JAWS, VoiceOver, TalkBack) by providing semantic HTML and ARIA.
Why
A screen reader is how a blind or low-vision user accesses the app — it reads the page aloud (or outputs to a braille display) and navigates by semantic structure (headings, regions, forms, links). Without semantic HTML and ARIA, the screen reader sees a pile of divs with no meaning: it cannot announce a button as a button, a form error as an error, or a cart update as a change. The user is lost. Semantic HTML (button, nav, main, h1-h6, label, fieldset) provides the meaning for free, and ARIA (aria-label, aria-live, aria-expanded, role) fills the gaps for custom widgets. Supporting screen readers is not a niche concern — it is how 7 million blind users in the US alone access the web, and it is a legal requirement under the ADA.
When
For every user-facing feature. Test with a screen reader during development of custom widgets, and as a full pass before launch. Re-test when dynamic content or custom interactions change.
How
1. Use semantic HTML first — it is the foundation
- <button>, <a>, <input> are announced correctly without any ARIA
- <nav>, <main>, <aside>, <footer> create navigable regions
- <h1> to <h6> create a heading outline the user can navigate by (H key)
- <label> associates with <input> so the field is announced with its label
- <fieldset> + <legend> groups related inputs (radio buttons, checkboxes)
2. Add ARIA only where semantic HTML is insufficient
- aria-label: provide a label when there is no visible text (icon-only button)
- aria-labelledby: point to a visible element that labels this one
- aria-describedby: point to help text or an error message
- aria-live="polite": announce dynamic changes (cart update, toast, search results)
- aria-expanded: indicate whether a dropdown or accordion is open or closed
- role: override the implicit role only when necessary (rare — prefer semantic HTML)
3. Test with actual screen readers
- NVDA (free) + Chrome or Firefox on Windows — the most common combination
- VoiceOver + Safari on Mac (Cmd+F5 to toggle)
- TalkBack on Android (Settings → Accessibility)
- Navigate by: H (headings), F (forms), K (links), R (regions), Tab (interactive)
4. Verify the announcement quality
- Does each interactive element announce its role and label? ("Submit, button")
- Are form errors announced? (aria-describedby pointing to the error, or aria-live)
- Are dynamic changes announced? (aria-live="polite" for cart, "assertive" for errors)
- Is the heading structure logical? (one h1, then h2s, no skipped levels)
5. Handle the common patterns
- Icon-only button: <button aria-label="Close menu"><XIcon /></button>
- Loading state: <div role="status" aria-live="polite">Loading...</div>
- Error message: <div role="alert">{error}</div> (aria-live="assertive" is implicit)
- Tab widget: use role="tab", role="tablist", role="tabpanel", aria-selected, aria-controlsExample
// Semantic HTML: form with labels and errors
<form>
<fieldset>
<legend>Shipping Address</legend>
<div>
<label htmlFor="street">Street address</label>
<input id="street" type="text" aria-describedby="street-error" required />
<div id="street-error" role="alert">{errors.street}</div>
</div>
</fieldset>
<button type="submit">Place order</button>
</form>
// Screen reader: "Street address, edit, required. [error] Street is required."
// Icon-only button: provide an accessible name
<button aria-label="Close dialog" onClick={close}>
<XIcon aria-hidden="true" />
</button>
// Screen reader: "Close dialog, button"
// Dynamic content: announce cart updates
function AddToCartButton() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Add to cart</button>
<div role="status" aria-live="polite">
{count} items in cart
</div>
</div>
);
}
// Screen reader: "Add to cart, button. 0 items in cart."
// After click: "1 items in cart." (announced automatically)
// Custom tab widget: use ARIA roles and properties
<div role="tablist">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">Details</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2">Reviews</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>
// Test with NVDA: press H to navigate by headings, F by forms, Tab through interactive
// Verify: every element announces its role, label, and stateCommon Problems
- ⚠Adding role="button" to a <div onClick> instead of using a <button> — the div now announces as a button but is still not keyboard-focusable or keyboard-activatable, so the screen reader user knows it is a button but cannot use it.
- ⚠Using aria-label that duplicates the visible text — the screen reader announces "Close, Close" because the aria-label overrides and repeats the text, making the output worse than no ARIA.
- ⚠Forgetting aria-live on dynamic content — the cart count updates visually but the screen reader user is never told, and they think the button did not work.
- ⚠Using heading levels for styling (h2 for a smaller font, h3 for bold) instead of for structure — the heading outline is meaningless, and the screen reader user cannot navigate by it.
Best Practices
- ✓Use semantic HTML (button, nav, main, h1-h6, label, fieldset) as the foundation — it provides the role, label, and keyboard support for free, and ARIA is only needed for custom widgets.
- ✓Test with at least two screen readers (NVDA + VoiceOver) on real browsers — different screen readers interpret ARIA differently, and testing both catches the inconsistencies.
- ✓Use aria-live="polite" for non-urgent updates (cart, search results) and role="alert" (implicit assertive) for errors — the politeness level determines whether the announcement interrupts the user.
- ✓Maintain a logical heading structure (one h1, then h2s, no skipped levels) — the heading outline is the screen reader user's primary navigation method, and a broken structure makes the page unnavigable.