frontend · level 6

Accessibility

Semantic HTML beats ARIA. Keyboard before mouse. Focus is content.

200 XP

Accessibility

Most of accessibility is structure, not magic. The mistakes that block real users — missing labels, broken focus, keyboard traps, low contrast — are bugs you can audit, fix, and regression-test like any other defect. The myth is that accessibility is a heroic overhaul; the reality is that it's a hundred two-line fixes spread across a codebase.

Analogy

Imagine a building with a beautiful glass facade but no door handles, only photo-eye sensors that recognize specific faces. Anyone the sensor knows walks straight in. Anyone it doesn't — a contractor in a mask, the night-shift cleaner, a delivery driver with their hands full — bounces off the glass and gives up. The fix isn't to rebuild the building; it's to install ordinary handles that anyone can grip. Semantic HTML and keyboard support are those handles. They cost almost nothing if you put them in at the start, and they make the building work for everyone — including the people the sensors were never trained on.

Semantic HTML beats ARIA

The first rule of ARIA, paraphrased from the spec: don't use ARIA if a native element already does the job.

<!-- The native button gives you focus, role, keyboard handling, hover state. -->
<button>Save</button>

<!-- This re-implements all of the above and adds a place for bugs. -->
<div role="button" tabindex="0" onClick={save} onKeyDown={handleKey}>Save</div>

Native HTML elements ship with a free accessibility tree node, free keyboard handling, free focus management, free state semantics. The moment you replace <button> with <div>, you owe assistive tech all four — usually you only remember to add the first one.

The accessibility tree

Browsers maintain a parallel tree of accessibility nodes alongside the DOM. Each node has a role, an accessible name, a description, and a state. Screen readers, voice control, and search engines navigate this tree, not the DOM.

Element Role Accessible name source
<button>Save</button> button the text content
<button aria-label="Close">×</button> button aria-label
<input id="email"><label for="email">Email</label> textbox the <label>
<img src="logo.svg" alt="Acme"> image the alt
<div onClick=…>Save</div> none invisible to screen readers

Run the page through Chrome DevTools → Accessibility pane and read the tree. It's the spec a screen reader sees.

Keyboard navigation is the floor

If your interface doesn't work with a keyboard alone, it doesn't work for:

  • screen-reader users (most of whom drive with a keyboard)
  • users with motor disabilities (cannot use a mouse precisely)
  • power users who tab through forms three times faster than they click
  • anyone whose mouse breaks during a deploy at 2am

The contract:

  1. Every interactive element is reachable by Tab.
  2. Every interactive element is operable by Enter (links) or Space (buttons, checkboxes).
  3. Focus follows reading order (top-to-bottom, left-to-right in LTR languages).
  4. Focus state is visible — never outline: none without a replacement.
/* Don't kill the focus ring. Replace it. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}

:focus-visible (vs :focus) only shows the ring for keyboard-driven focus, not after a mouse click — so the design stays clean for mouse users while keyboard users still see where they are.

Focus management

Focus is content. When the page changes, focus needs to move with the user's attention.

  • Modal opens. Move focus into the modal. Trap Tab inside it. On close, restore focus to the element that opened it.
  • Route change (single-page app). Move focus to the page's <h1> so screen-reader users know they've landed somewhere new. Without this, only the URL changed, and assistive tech is silent.
  • Async error. Move focus to the error region (role="alert" for assertive announcement, role="status" for polite).
function Modal({ open, onClose, children }: ModalProps) {
  const ref = useRef<HTMLDivElement>(null);
  const opener = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;
    opener.current = document.activeElement as HTMLElement;
    ref.current?.focus();

    return () => opener.current?.focus(); // restore on close
  }, [open]);

  // Trap Tab inside ref.current — see config.ts code example for the trap fn.
  return open ? <div ref={ref} tabIndex={-1} role="dialog" aria-modal="true">{children}</div> : null;
}

Common bugs

Icon-only buttons without a name. <button><TrashIcon /></button> reads as just "button" with no name. Fix with aria-label on the button and aria-hidden="true" on the SVG.

Form fields without labels. Every <input>, <select>, <textarea> needs a programmatic label — <label for>, aria-label, or aria-labelledby. Placeholders are not labels; they vanish on focus.

Custom dropdowns that aren't. A <div> toggling a <ul> of <li onClick> items is a <select> cosplay with all of <select>'s features missing — keyboard navigation, type-ahead, screen-reader announcement, native mobile UI. Use <select> unless the design genuinely cannot.

Focus trap, opposite direction. A modal that traps Tab forwards but lets Shift+Tab escape behind it. Both directions matter.

Heading order skips. h1h3 because the design called for smaller text. Fix the CSS, not the markup. Heading order is structure; size is style.

Color as the only signal. A red border on an invalid field is invisible to a colour-blind user. Add an icon, an error message, or aria-invalid on top of the colour change.

Contrast

WCAG 2.1 AA, the standard most teams aim for:

  • 4.5 : 1 minimum for normal-size body text against its background
  • 3 : 1 minimum for large text (18.66 px+ bold, 24 px+ regular) and UI components / focus rings
  • 7 : 1 for AAA, sometimes required for legal compliance (banking, government)

Test with the browser DevTools contrast picker. A 12-px placeholder at #999 on #fff is 2.85:1 — fails AA. Designers will fight this; show them a screenshot at 50% brightness on a sunlit phone screen.

Testing

Three layers, each catches a different class of bug.

Static / unit. axe-core (used by axe DevTools, jest-axe, Pa11y) catches the ~30% of WCAG issues a machine can prove from markup alone — missing alt, missing label, low contrast, duplicate IDs.

import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);

it("login form has no a11y violations", async () => {
  const { container } = render(<LoginForm />);
  expect(await axe(container)).toHaveNoViolations();
});

Integration. Testing-library's getByRole("button", { name: "Save" }) queries the same accessibility tree that screen readers use. If your test passes, the tree is reasonable. If it fails, the tree is broken.

Manual. Drive the feature with a screen reader for ten minutes per release. VoiceOver (Cmd+F5 on macOS), NVDA (free, Windows), JAWS (paid, used in many enterprise environments). The first time is humbling. The third time is fast.

When to actually use ARIA

ARIA is the escape hatch for patterns HTML doesn't have:

  • A custom role="combobox" with aria-expanded, aria-controls, aria-activedescendant
  • A role="tablist" / role="tab" / role="tabpanel" group
  • A role="alert" live region that announces async errors
  • aria-busy on a region that's loading

The ARIA Authoring Practices Guide (APG) lists working examples for every common widget. Copy from there before inventing.

Practical checklist

  • Every <img> has alt ("" for decorative).
  • Every form control has a programmatic label.
  • Every interactive element is reachable and operable from the keyboard.
  • Focus is visible on every interactive element.
  • Heading order is unbroken (h1 → h2 → h3 → ...).
  • Modals trap focus and restore it on close.
  • Route changes move focus to a meaningful target.
  • Color is never the only signal for state.
  • axe + at least one screen reader pass before merge.

Tools in the wild

5 tools
  • axe DevToolsfree tier

    Browser extension; flags missing labels, low contrast, focus traps. The default audit on every PR.

    service
  • Real screen readers. macOS VoiceOver (Cmd+F5) and free NVDA on Windows. The ground truth.

    cli
  • Pa11yfree tier

    Headless accessibility CI runner — runs HTML_CodeSniffer or axe against URLs, emits machine-readable diffs.

    cli
  • Matchers like `toHaveAccessibleName` / `toBeInTheDocument` — let you assert on the a11y tree from unit tests.

    library
  • Lighthouse CIfree tier

    Runs Lighthouse on every PR; sets a budget for the accessibility score so regressions block merge.

    cli