Building Accessible Interfaces
Overview
Accessibility is not a pass applied at the end. Almost every accessibility defect is a structural decision — a div that should have been a button, a state communicated only in color, a modal that never moved focus — and structural decisions are expensive to reverse.
Core principle: Native semantic HTML is accessible by default. Every layer you add on top of it is a layer you now own the accessibility of.
Target: WCAG 2.2 Level AA.
The Iron Law
NO CUSTOM CONTROL WHERE A NATIVE ELEMENT EXISTS
A <div onclick> needs a role, a tabindex, key handlers for Enter and Space, a focus style, and a disabled state. A <button> needs none of that — it arrives with all of it, correct on every platform and assistive technology, for free.
Wrote <div onclick>? Replace it with <button>. Do not add ARIA to make the div behave like a button — ARIA changes what assistive tech announces, never what the browser does.
Checklist
- Semantics first — correct elements, landmarks, heading order
- Keyboard — everything operable, visible focus, logical order, no traps
- Names — every control and image has an accessible name
- Contrast — text 4.5:1, large text and UI boundaries 3:1
- Forms — labels, error association, announcement
- Dynamic content — live regions for anything that changes without navigation
- Motion and targets — reduced-motion respected, targets ≥ 24px (44px recommended)
- Verify — automated scan, then a keyboard walk, then a screen reader
1. Semantics
Use the Right Element
| Need | Element | Never |
|---|---|---|
| Navigates to a URL | <a href> |
<button> |
| Performs an action | <button> |
<div onclick>, <a href="#"> |
| Group of options, one choice | <input type="radio"> |
styled divs |
| Show/hide content | <details><summary> |
div + JS toggle |
| Modal dialog | <dialog> |
div with position: fixed |
| Selection from a list | <select> |
custom listbox, unless genuinely required |
| Tabular data | <table> with <th scope> |
grid of divs |
| Progress | <progress> |
animated div |
The <a> vs <button> distinction is not cosmetic: links support open-in-new-tab, copy-link, and activate on Enter only; buttons activate on Enter and Space. Screen-reader users navigate by element type, so a "button" in the links list is genuinely misleading.
Landmarks
Exactly one <main>. Wrap navigation in <nav>. Use <header>, <footer>, <aside>. Screen-reader users jump between landmarks constantly; a page of nested divs offers nothing to jump to.
<a href="#main" class="skip-link">Skip to content</a>
<header>…</header>
<nav aria-label="Primary">…</nav>
<main id="main" tabindex="-1">…</main>
<footer>…</footer>
Multiple <nav> elements each need a distinguishing aria-label ("Primary", "Breadcrumb", "Pagination").
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: var(--space-s); top: var(--space-s); z-index: 9999; }
Headings
One <h1> per page. Never skip levels (h2 → h4 is a defect). Heading level communicates document structure — pick it for structure and set the size with CSS.
2. Keyboard
Every interactive element must be reachable and operable by keyboard alone. This is the highest-yield check in this document and it takes ninety seconds: unplug the mouse and Tab through the page.
| Key | Expected |
|---|---|
Tab / Shift+Tab |
Move between focusable elements in DOM order |
Enter |
Activate link or button |
Space |
Activate button, toggle checkbox, scroll page |
Esc |
Close modal, popover, menu; cancel the current interaction |
Arrows |
Move within a composite widget — radio group, tabs, menu, listbox |
Home / End |
First / last item in a composite widget |
Roving tabindex — a composite widget is one tab stop, with arrows moving inside it:
// Only the active item is tabbable; arrows move the active item.
items.forEach((el, i) => el.tabIndex = i === active ? 0 : -1);
Tabs, menus, toolbars, and listboxes all use this. A tab list where every tab is a separate tab stop is incorrect.
Focus Visibility
:focus-visible {
outline: 2px solid var(--focus, currentColor);
outline-offset: 2px;
border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; } /* no ring on mouse click */
Never outline: none without a replacement. The focus indicator must have 3:1 contrast against adjacent colors and be at least 2px thick (WCAG 2.2 SC 2.4.11/2.4.13).
Focus Management
Focus must move when context changes, and return when it closes.
// Opening
lastFocused = document.activeElement;
dialog.showModal(); // <dialog> traps focus natively
dialog.querySelector('h2').focus(); // or the first meaningful control
// Closing
dialog.close();
lastFocused?.focus(); // MUST return focus
Never trap focus outside a modal. A trap that Esc cannot escape is WCAG 2.1.2, a Level A failure — it can leave a keyboard user stuck on the page permanently.
Route changes in SPAs: move focus to the new page's <h1> or <main tabindex="-1"> and announce the new title. Without this, a screen-reader user has no idea navigation occurred.
Never move focus unprompted. Auto-focusing a field mid-typing, or jumping focus on a background update, disorients everyone.
3. Accessible Names
Every control needs a name. Check this in devtools' Accessibility pane — the computed name, not the markup you intended.
<!-- Visible label — best -->
<label for="q">Search</label><input id="q">
<!-- Icon-only button -->
<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>
<!-- Label a region by an existing heading -->
<section aria-labelledby="h-recent"><h2 id="h-recent">Recent</h2>…</section>
Rules:
- Decorative images:
alt="". Omittingaltentirely makes screen readers read the filename. - Informative images:
altdescribes the information, not the picture. A chart's alt is its finding. - Icons inside a labelled control:
aria-hidden="true"so the name is not doubled. - Link text must make sense out of context. "Read more" ×8 on a page is useless; screen-reader users list links.
aria-labeloverrides visible text. If a button reads "Save" but hasaria-label="Submit", voice-control users saying "click Save" fail. Keep them matching.
4. Contrast
| Content | Ratio |
|---|---|
| Body text | 4.5:1 |
| Text ≥ 24px, or ≥ 18.66px bold | 3:1 |
| Icons and UI boundaries that carry meaning | 3:1 |
| Focus indicator vs. adjacent colors | 3:1 |
Measure, do not estimate. See designing-frontend-interfaces/references/color-and-theme.md for the formula, and applying-themes/scripts/check_contrast.py for a runnable checker.
Color is never the only signal. Add an icon, a label, a pattern, or a shape wherever color conveys state — see the table in that same reference.
5. Forms
<div class="field">
<label for="email">Email address</label>
<p id="email-hint" class="hint">We'll only use this to send your receipt.</p>
<input id="email" type="email" inputmode="email" autocomplete="email"
aria-describedby="email-hint email-err" aria-invalid="true" required>
<p id="email-err" class="error">Enter an email address with an @.</p>
</div>
| Rule | Why |
|---|---|
Every input has a <label for> |
Placeholders are not labels — they vanish on focus and fail contrast |
Errors linked via aria-describedby |
Otherwise the error is invisible to screen readers |
aria-invalid="true" on invalid fields |
Announces the state, not just the message |
Hints and errors both in aria-describedby |
Space-separated ID list; both get announced |
Never disabled on a submit as validation |
Disabled elements are skipped by Tab and give no reason |
Group related inputs in <fieldset><legend> |
Radio groups and checkbox sets need the group name announced |
required attribute, not just a red asterisk |
Announced natively |
On submit failure: move focus to the first invalid field, or to a summary that links to each error.
6. Live Regions
Anything that changes without a page navigation must be announced.
<div aria-live="polite" aria-atomic="true" class="sr-only" id="status"></div>
<div role="alert" id="errors"></div> <!-- implicitly assertive -->
| Region | Use for | Interrupts? |
|---|---|---|
aria-live="polite" |
Status, results count, save confirmations | No — waits for a pause |
role="alert" / assertive |
Errors, session expiry, destructive warnings | Yes |
Critical: the live region element must exist in the DOM before the content changes. Injecting an element that already contains text announces nothing.
// ✅ Region already present; only its text changes
status.textContent = `${n} results`;
// ❌ Injecting a populated live region — silent
container.innerHTML = '<div aria-live="polite">12 results</div>';
Use sparingly. Three live regions competing produce unusable chatter.
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0;
margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
Note .sr-only content is still focusable if it contains controls — use .sr-only:focus-within styles to reveal skip links.
7. Motion and Targets
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important; animation-iteration-count: 1 !important;
transition-duration: .01ms !important; scroll-behavior: auto !important;
}
}
- Nothing may flash more than 3 times per second (seizure risk, WCAG 2.3.1).
- Auto-playing motion over 5 seconds needs a pause control (WCAG 2.2.2).
- Carousels need pause, and must not auto-advance while focused.
Target size (WCAG 2.2 SC 2.5.8, AA): 24×24 CSS px minimum. 44×44 is the practical recommendation. Enlarge the hit area without changing the visual size:
.icon-btn { position: relative; }
.icon-btn::after { content:""; position:absolute; inset:-10px; }
8. The Five Rules of ARIA
- Use a native element instead if one exists with the semantics you need.
- Do not change native semantics —
<button role="heading">is wrong; wrap instead. - All interactive ARIA must be keyboard-operable — a
role="button"div needstabindex="0"and Enter/Space handlers. - Never
role="presentation"oraria-hidden="true"on a focusable element — it becomes focusable but unannounced, which is the worst possible state. - Every interactive element needs an accessible name.
No ARIA is better than bad ARIA. Incorrect ARIA actively misinforms; missing ARIA merely falls back to the native behavior.
Component Patterns
Dialog
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete 47 records?</h2>
<p>Records in Q3 Archive will be permanently removed.</p>
<button data-close>Keep records</button>
<button data-confirm class="danger">Delete 47 records</button>
</dialog>
dialog.showModal() provides focus trapping, Esc-to-close, inert background, and the ::backdrop pseudo-element natively. Still required of you: focus something meaningful on open, and restore focus on close.
Disclosure
<button aria-expanded="false" aria-controls="panel-1">Shipping details</button>
<div id="panel-1" hidden>…</div>
Toggle both aria-expanded and hidden. Or use <details><summary> and write no JavaScript at all.
Tabs
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">Profile</button>
<button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
Roving tabindex; arrows move between tabs; Home/End jump to first/last.
Toast
<div aria-live="polite" id="toasts"></div> <!-- present at page load -->
Errors use role="alert". Never auto-dismiss anything the user must act on — WCAG 2.2.1 requires that time limits be adjustable.
Combobox
The hardest common pattern. Use a maintained library (React Aria, Radix, Headless UI) rather than hand-rolling it. If hand-rolling: role="combobox" with aria-expanded, aria-controls, and aria-activedescendant pointing at the highlighted option's id — focus stays in the input while arrows move the highlight.
Verification
Automated tools catch roughly 30-40% of issues. All three passes are required.
1. Automated scan
npx @axe-core/cli http://localhost:5173 --exit
npx lighthouse http://localhost:5173 --only-categories=accessibility --view
Or in a Playwright test — see the testing-webapps skill:
page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
violations = page.evaluate("async () => (await axe.run()).violations")
assert not violations, [v["id"] for v in violations]
2. Keyboard walk — no mouse:
- Tab reaches every control, in an order matching the visual layout
- Focus is always visible — never lost, never invisible on any background
- Enter and Space activate what they should
- Esc closes every overlay
- Arrows work inside tabs, menus, and radio groups
- Opening a modal moves focus in; closing it returns focus to the trigger
- Nothing is reachable only by hover
- No focus trap outside a modal
3. Screen reader — 5 minutes with the real thing:
| Platform | Reader | Start / stop |
|---|---|---|
| macOS | VoiceOver | Cmd+F5 |
| Windows | NVDA (free) | Ctrl+Alt+N |
| Linux | Orca | Super+Alt+S |
Navigate by heading (H), by landmark (D), and by form control (F). Every control should announce its name, its role, and its state.
Common Mistakes
<div onclick> instead of <button>. The single most common defect. Not keyboard-operable, no role, no focus, no disabled state.
outline: none with no replacement. Removes the only signal keyboard users have about where they are.
Placeholder as label. Disappears on focus, fails contrast, breaks autofill, unreliable for screen readers.
Focus never returns from a modal. Focus lands back at <body>, forcing a full re-tab through the page.
Live region injected with its content. Announces nothing. The element must pre-exist.
aria-label contradicting visible text. Breaks voice control — the user says the word they can see, and nothing happens.
Icon-only buttons with no name. Announced as "button". Every icon button needs aria-label.
Heading levels chosen for size. <h4> picked because it looked right. Set the level for structure and the size in CSS.
Contrast checked in light mode only. Dark mode passing is a separate check.
aria-hidden="true" on something focusable. Reachable by Tab but announced as nothing. Use inert on hidden containers instead.