Accessibility
02 / 02

Keyboard, Focus & Testing

Keyboard, Focus & Testing

Tab order, focus management, skip links, color contrast, and the tooling that catches regressions before users do.

Keyboard Navigation

  • Every interactive element must be reachable and operable with the keyboard alone. Test by unplugging your mouse for 5 minutes.

  • Tab order follows DOM order. Avoid `tabindex` values > 0 — they create surprising jumps.

  • tabindex="0" — make a non-focusable element focusable (custom widgets).

  • tabindex="-1" — focusable programmatically (.focus()) but skipped by Tab.

  • Escape closes dialogs, popovers, menus. Always. Without exception.

<!-- Skip link — first focusable element, lets keyboard users skip the nav -->
<a href="#main" class="skip-link">Skip to main content</a>
<style>
  .skip-link {
    position: absolute; top: -40px; left: 0;
    background: #000; color: #fff; padding: 8px 16px;
    z-index: 100;
  }
  .skip-link:focus { top: 0; }   /* visible only when focused */
</style>

<header><nav>...</nav></header>
<main id="main" tabindex="-1">...</main>

<!-- Custom focus indicator — never `outline: none` without a replacement -->
<style>
  :focus-visible {
    outline: 2px solid #06f;
    outline-offset: 2px;
  }
</style>

Focus Management in Dialogs

// Modal dialog opening behavior:
//   1. Save the element that had focus (the trigger)
//   2. Move focus into the dialog (first focusable, or the dialog itself)
//   3. Trap focus inside while open (Tab cycles within)
//   4. Esc closes
//   5. Return focus to the trigger on close

// In 2024+, prefer the native <dialog> element — handles all of this:
const dialog = document.querySelector('dialog')
dialog.showModal()    // backdrop, focus trap, Esc-to-close — free
dialog.close()        // restores focus to invoker

// React: Radix UI, Headless UI, react-aria — battle-tested primitives.
// Don't hand-roll focus traps unless you have a strong reason.

// Single-page app route changes — focus the new <main> or <h1>:
function onRouteChange() {
  const main = document.getElementById('main')
  main?.focus()
  // Optionally announce: a polite live region with the new page title
}

Color Contrast & Motion

WCAG 2.2 contrast targets (text vs background):
  Normal text     ≥ 4.5:1   (AA)   ≥ 7:1   (AAA)
  Large text      ≥ 3:1     (AA)   ≥ 4.5:1 (AAA)
  Non-text UI     ≥ 3:1     (AA)   — buttons, focus rings, icons

Large = 24px+ or 19px+ bold.

Tools:
  Chrome DevTools — Inspect → Contrast ratio shown on color picker
  axe DevTools    — flags failing combinations
  WebAIM checker  — quick HEX comparison

Motion & animation:
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.01ms !important;
      transition-duration: 0.01ms !important;
    }
  }
  Respects user OS setting. Required for AA conformance.

Testing

// Automated tools catch ~30% of issues. The other 70% needs human checks.

// 1) axe-core in unit tests
import {axe} from 'jest-axe'
it('has no a11y violations', async () => {
  const {container} = render(<MyForm />)
  expect(await axe(container)).toHaveNoViolations()
})

// 2) Playwright + @axe-core/playwright in e2e
import AxeBuilder from '@axe-core/playwright'
test('home a11y', async ({page}) => {
  await page.goto('/')
  const results = await new AxeBuilder({page}).analyze()
  expect(results.violations).toEqual([])
})

// 3) Lighthouse CI in your pipeline — accessibility score must stay ≥ 95

// 4) Manual checks no tool can do:
//      - Tab through the entire page — order makes sense, focus visible
//      - Use VoiceOver (Cmd+F5) / NVDA — does it sound right?
//      - Zoom to 200% — does anything break or get cut off?
//      - Try forms with autofill — do labels match autocomplete tokens?
//      - Read out loud the alt text — does it duplicate nearby caption?

Quick Audit Checklist

  • Page has exactly one <h1>; heading levels do not skip.

  • Every image has alt text (or alt="" if purely decorative).

  • All form inputs have visible labels and an autocomplete value where applicable.

  • Focus is visible on every interactive element (no outline removal without replacement).

  • Color contrast meets AA. Information is not conveyed by color alone.

  • Dialogs trap focus, return it on close, and respond to Esc.

  • prefers-reduced-motion is respected for animations.

  • Page is navigable end-to-end with the keyboard only.

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free