Accessibility

10 min

Accessibility

Accessibility is not a feature you add at the end. It's a set of structural decisions that, when made early, cost nothing—and when retrofitted late, cost everything. Most accessibility bugs in modern frontends come from the same handful of mistakes: divs instead of buttons, missing labels on icon-only controls, broken heading hierarchies, and focus indicators stripped without replacement.

This lesson covers the high-leverage patterns that prevent the majority of accessibility failures.

Semantic HTML before ARIA

The first rule of ARIA is: don't use ARIA. A <button> already has the right role, keyboard handling, accessibility tree presence, and form-submit behaviour. A <div role="button" tabIndex={0}> reproduces all of that worse, and you'll forget at least one of the four.

The most common offenders:

  • Clickable <div> or <span>. Use <button type="button">. If it navigates, use <a href> or your framework's <Link>.
  • Custom dropdowns instead of <details> / <summary> for simple disclosure.
  • Custom checkboxes instead of <input type="checkbox">. Style the input; don't replace it.
  • <div role="navigation"> instead of <nav>. The native element is already a navigation landmark.
  • Heading text in a <div class="title">. Use <h1><h3>. Screen readers and search both rely on heading structure.

When ARIA is appropriate: tab panels, live regions, dialogs that aren't <dialog>, custom comboboxes—all need ARIA roles and attributes. Once native elements don't exist or don't fit, ARIA fills the gap. But reach for it second, not first.

Icon buttons need aria-label

A trash-can icon means "delete" to a sighted user. To a screen reader user, it's an unlabelled button—useless without help.

<button aria-label="Delete" onClick={handleDelete}>
  <TrashIcon aria-hidden="true" />
</button>

Rules of thumb:

  • Visible text wins. If a button already has text, that text is the accessible name. Don't add aria-label—it overrides.
  • Decorative icons get aria-hidden. Icons inside a labelled button (<button>Delete <TrashIcon /></button>) should have aria-hidden="true" so the screen reader doesn't double-announce.
  • Be specific. "Close" beats "Button." "Delete this comment" beats "Delete." "Open menu" beats "Menu."
  • Never contradict. <button aria-label="Cancel">Submit</button> is a bug. Fix whichever is wrong.

Skip links

A skip link is a hidden-until-focused link at the top of every page that jumps past navigation to main content. Without it, keyboard users Tab through every nav item on every page visit—hundreds of unnecessary key presses per session.

<a href="#main" class="skip-link">Skip to main content</a>
<!-- ...navigation... -->
<main id="main">...</main>
.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 1rem;
  top: 1rem;
  z-index: 9999;
}

The link is invisible until it receives focus (Tab from page load), then appears and a press of Enter takes the user to #main. Test by tabbing from page load—if nothing appears, the skip link is broken.

Heading hierarchy

Once focus lands in <main>, users navigate by headings. Screen readers offer a "next heading" shortcut; if headings are descriptive and properly nested, the user reaches their target in seconds.

The rules:

  • One <h1> per page. It's the page title.
  • Never skip levels. <h1> then <h3> (skipping <h2>) breaks the outline. Screen reader users navigating "by heading level" will miss the content.
  • Headings describe sections. "Section 1" is useless. "Billing history" is useful.
  • Don't use headings for styling. If you want big bold text, use CSS—not an <h2> on a non-heading element.

Contrast

WCAG defines minimum contrast ratios: 4.5:1 for body text and 3:1 for large text and non-text UI elements. But contrast isn't just about accessibility compliance—it's about legibility for everyone.

Test both light and dark modes with a contrast checker. Dark mode often makes contrast worse if the design is just flipped—pure white on pure black is a glare problem; light grey on dark grey often falls below the floor.

WCAG Contrast Checker

Large text sample

Body text at 16px — the quick brown fox jumps over the lazy dog. WCAG defines body text as anything below 18pt (24px) or 14pt (18.66px) bold.

Small print legalese also lives here.

Normal text
AA (4.5:1) PassAAA (7:1) Pass
Large text
AA (3:1) PassAAA (4.5:1) Pass
Presets
Contrast ratio: 19.80:1

Never rely on colour alone. Information signalled only by colour fails for colour-blind users, monochrome screens, and printed pages. Always pair colour with a redundant cue:

  • Error fields: colour + icon + label + accessible role.
  • Required fields: colour + asterisk + screen-reader-only "required" text.
  • Active tab: colour + underline or weight change.

:focus-visible is non-negotiable

Never apply outline: none without a :focus-visible replacement. This is the single most common accessibility bug in modern frontend code—Tailwind's outline-none applied globally with no substitute.

:focus-visible {
  outline: 2px solid var(--focus-color);
  outline-offset: 2px;
}

A keyboard user tabbing through the page sees a clear ring on each focused element. A mouse user clicking a button does not. Both groups get what they need. The browser decides the heuristic—Tab, arrow keys, and programmatic focus after keyboard interaction trigger visibility; mouse clicks don't.

Common mistakes

  • Click handler on a <div>. Mouse-only. Doesn't work on Space, Enter, or screen readers. Use <button>.
  • outline: none globally with no replacement. Users navigating by keyboard can't see where they are.
  • Skip link present but never visible. CSS hid it from focus too. Test by tabbing from page load.
  • aria-label that contradicts visible text. Confuses screen reader users who can also see the screen.
  • tabIndex="-1" everywhere. Removes elements from the tab order. Use only on programmatically-focused containers.
  • autocomplete="off" on password fields. Doesn't improve security. Breaks password managers.