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