Web Accessibility
Accessibility (a11y) means people with disabilities can perceive, understand, navigate and interact with your product. That includes people using screen readers, keyboards only, voice control, zoom, high-contrast modes, and people with motor, cognitive or temporary limitations. It is a quality bar, a legal requirement in many places, and a frequent interview topic because it exposes whether you understand the platform. This chapter gives you the principles, the patterns, and a checklist you can apply to any component.
1. The four principles (POUR)
The Web Content Accessibility Guidelines (WCAG) organise requirements under four principles:
- Perceivable: information can be presented in ways users can perceive (text alternatives, captions, contrast, resizable text).
- Operable: everything works with a keyboard and other input methods, with enough time, no seizure-inducing flashes, clear navigation.
- Understandable: readable text, predictable behaviour, helpful errors.
- Robust: works with current and future assistive technology, which means valid, semantic markup.
Conformance levels are A, AA and AAA; AA is the usual target. Check the current WCAG version (2.2 at the time of writing) for the exact criteria.
2. Start with semantic HTML
The first rule of ARIA: do not use ARIA if a native element does the job. Native elements bring role, name, state, keyboard behaviour and focus handling for free.
| Need | Native element |
|---|---|
| Button | <button> |
| Link | <a href> |
| Text input | <input> with <label> |
| Checkbox, radio | <input type="checkbox" / "radio"> |
| Dropdown | <select> |
| Dialog | <dialog> |
| Disclosure | <details> and <summary> |
| Landmarks | <header>, <nav>, <main>, <footer>, <aside>, <section aria-label> |
| Headings | <h1> to <h6> in logical order |
| Lists, tables | <ul>, <ol>, <table> with <th scope> and <caption> |
A div with onClick is not focusable, not announced as a button, and does not respond to Enter or Space. If you must build a custom control, you owe all of that behaviour by hand.
3. Names, roles, states
Assistive technology needs three things for every interactive element:
- Name: what it is called (from content, a
<label>,aria-labeloraria-labelledby). - Role: what kind of thing it is.
- State and value: expanded, selected, checked, disabled, current value.
An icon-only button needs a name: <button aria-label="Close dialog"><svg aria-hidden="true">...</svg></button>.
Text alternatives: meaningful images get descriptive alt; decorative images get alt=""; complex charts get a nearby text description or data table.
4. Keyboard support
Every action available by mouse must work by keyboard.
- Tab order follows the DOM. Do not rearrange visual order with CSS in ways that contradict it. Avoid positive
tabindex; usetabindex="0"to add a custom control to the order andtabindex="-1"to allow programmatic focus only. - Visible focus is mandatory. Never
outline: nonewithout a replacement; prefer:focus-visible. - Standard keys: Enter and Space activate buttons; Enter follows links; Esc closes overlays; arrow keys move within composite widgets (tabs, menus, listboxes, radio groups); Home and End jump to first and last.
- Skip link ("Skip to main content") as the first focusable element.
- No keyboard traps, except intentional ones such as a modal, which must have an exit.
Roving tabindex
Composite widgets (a toolbar, tabs) expose one tab stop; arrow keys move focus within them.
// roving focus logic: which item receives focus after a key press
function nextIndex(current, key, count) {
switch (key) {
case "ArrowRight": case "ArrowDown": return (current + 1) % count; // wrap around
case "ArrowLeft": case "ArrowUp": return (current - 1 + count) % count;
case "Home": return 0;
case "End": return count - 1;
default: return current;
}
}
assert.equal(nextIndex(0, "ArrowRight", 3), 1);
assert.equal(nextIndex(2, "ArrowRight", 3), 0);
assert.equal(nextIndex(0, "ArrowLeft", 3), 2);
assert.equal(nextIndex(1, "End", 3), 2);
assert.equal(nextIndex(1, "a", 3), 1);
5. Focus management
- Modals: on open, move focus inside (to the dialog or its first control); trap Tab within it; make the background inert (
inertattribute oraria-modalwith a trap); on close, return focus to the element that opened it. The native<dialog>withshowModal()does much of this. - Route changes in single-page apps: the browser does not announce navigation, so move focus to the new page's heading or main region and update
document.title. - Removed elements: if the focused element disappears, send focus somewhere sensible.
// the Tab-trap rule: wrap from the last focusable to the first, and vice versa
function trapTab(index, count, shift) {
if (shift) return index === 0 ? count - 1 : index - 1;
return index === count - 1 ? 0 : index + 1;
}
assert.equal(trapTab(2, 3, false), 0);
assert.equal(trapTab(0, 3, true), 2);
assert.equal(trapTab(1, 3, false), 2);
6. ARIA when you need it
ARIA adds semantics; it never adds behaviour. Use it for custom widgets and dynamic states.
| Attribute | Use |
|---|---|
role | tells assistive tech what a custom element is (tablist, dialog, alert) |
aria-label / aria-labelledby | gives an accessible name |
aria-describedby | links help or error text to a control |
aria-expanded | whether a disclosure or menu is open |
aria-controls | which element a control operates |
aria-selected, aria-checked, aria-pressed, aria-current | state |
aria-hidden="true" | hides decorative content from assistive tech (never on focusable elements) |
aria-live (polite / assertive) | announces dynamic changes |
aria-invalid, aria-required | form state |
Live regions announce updates without moving focus: search result counts, toasts, form submission status. The region must exist in the DOM before its content changes. Use role="status" (polite) for ordinary updates and role="alert" for urgent errors.
Example: accessible disclosure
<button aria-expanded="false" aria-controls="faq1" id="faq1-btn">What is your refund policy?</button>
<div id="faq1" role="region" aria-labelledby="faq1-btn" hidden>Refunds are processed within 7 days.</div>
Toggle aria-expanded and the hidden attribute together. (The native <details> achieves this without any script.)
7. Forms
- Every input has a visible
<label>linked byforandid. Placeholders are not labels: they disappear and have poor contrast. - Group related fields in
<fieldset>with<legend>(radio groups, addresses). - Identify required fields in text, not colour alone, and use
requiredandaria-required. - Errors: describe what went wrong and how to fix it; place the message next to the field; link with
aria-describedby; setaria-invalid="true"; on a failed submit, move focus to the first error or an error summary. - Use the right
type,autocomplete(such asemail,one-time-code,street-address) andinputmode.
function errorFieldAttrs(id, error) {
return error
? { "aria-invalid": "true", "aria-describedby": `${id}-error` }
: { "aria-invalid": "false" };
}
assert.deepEqual(errorFieldAttrs("email", "Enter a valid email"), { "aria-invalid": "true", "aria-describedby": "email-error" });
assert.deepEqual(errorFieldAttrs("email", ""), { "aria-invalid": "false" });
8. Colour, contrast and visuals
- Contrast ratio of text against its background: at least 4.5:1 for normal text and 3:1 for large text (18.66px bold or 24px regular and above) at AA. UI components and graphical objects need 3:1.
- Do not rely on colour alone (add icons, text or patterns for errors and status).
- Support zoom to 200% and reflow at narrow widths without horizontal scrolling; use relative units.
- Respect
prefers-reduced-motion,prefers-contrastandprefers-color-scheme. - Avoid content that flashes more than three times per second.
- Provide captions and transcripts for media; do not autoplay audio.
The contrast ratio is computable from the WCAG relative-luminance formula:
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(fg, bg) {
const [l1, l2] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
return (l1 + 0.05) / (l2 + 0.05);
}
assert.ok(Math.abs(contrast("#000000", "#ffffff") - 21) < 1e-9); // the maximum
assert.ok(contrast("#777777", "#ffffff") < 4.5); // a common grey fails AA for body text
assert.ok(contrast("#595959", "#ffffff") >= 4.5); // a slightly darker grey passes
9. Dynamic and custom widgets
For complex widgets, follow the ARIA Authoring Practices Guide patterns for roles, states and keyboard interaction: tabs, menus, comboboxes (autocomplete), listboxes, dialogs, tree views. Example: an autocomplete needs role="combobox", aria-expanded, aria-controls pointing to the listbox, aria-activedescendant for the highlighted option, arrow-key navigation, Enter to select, Esc to close, and a live announcement of the number of results. Wherever possible use a well-tested library (Radix, React Aria, Headless UI) instead of building these from scratch.
10. Testing accessibility
Automated tools (axe, Lighthouse, eslint-plugin-jsx-a11y) catch only a portion of issues. Combine:
- Linting and automated checks in CI.
- Keyboard-only pass: can you reach and operate everything, with visible focus and a sensible order?
- Screen reader pass (VoiceOver, NVDA, TalkBack) on key flows: is every control named, are changes announced?
- Zoom and reflow to 200 to 400%, and small screens.
- Contrast and colour-blindness checks.
- Real users with disabilities where possible.
In unit and integration tests, query by role and accessible name (getByRole("button", { name: "Save" })). It both tests behaviour and enforces accessible markup.
11. Common mistakes
divandspanbuttons and links.- Removing the focus outline.
- Placeholder text as the only label.
- Missing or poor
alttext, oralton decorative images. - Colour-only meaning and low contrast.
- ARIA misuse: wrong roles,
aria-hiddenon focusable elements, redundant roles on native elements. - Modals that do not trap or restore focus.
- Silent dynamic updates (no live region).
- Positive
tabindexvalues and order that differs from the visual layout. - Treating accessibility as a late audit instead of a design and build habit.
12. Practice questions
- What are the first rule of ARIA and the four WCAG principles?
- Why is
<div onClick>a poor button? What must you replicate to fix it? - Build an accessible modal: what must happen on open, while open and on close?
- How do you make an autocomplete accessible?
- How do you announce dynamic content to screen reader users?
- What contrast ratios does WCAG AA require?
- How would you handle focus and announcements in a single-page app route change?
- How do you test accessibility beyond automated tools?