Overview
Accessibility is a contract with every user, assistive tech, and agent that parses your page. The working baseline: WCAG 2.2 AA as the floor, semantic HTML first, ARIA as a patch when nothing else fits, and a small set of tools that catch the common failures before production.
Treat WCAG 2.2 AA as the floor
Target WCAG 2.2 level AA on every public page. AA is the legal and pragmatic baseline across the EAA, the ADA, and most procurement checklists. AAA is a stretch target for specific surfaces (long-form reading, government forms), not a default.
The 2.2 update added requirements worth knowing: target sizes of at least 24x24 CSS pixels (2.5.8), visible focus that is not obscured by sticky headers (2.4.11), and accessible authentication that does not depend on a cognitive test (3.3.8). See mobile-first for the matching mobile touch-target and viewport rules.
Semantic HTML first
Use the element that names the role. Every native element brings keyboard handling, focus management, and an accessible name for free.
<button>for actions. Never style a<div>into a button.<a href="...">for navigation. An<a>withouthrefis not a link.<label>paired with every input. See forms.<fieldset>and<legend>to group radios, checkboxes, address blocks.<nav>,<main>,<header>,<footer>,<aside>for page landmarks.
A <div role="button" tabindex="0"> reimplements <button> and gets keyboard and screen reader behavior wrong. See html.
ARIA only when nothing native fits
ARIA does not change behavior; it changes what assistive tech announces. Reach for it only when no native element fits.
- A custom combobox with no HTML equivalent.
- A live region for async updates:
aria-live="polite". - An icon-only button:
aria-label="Close dialog". - A disclosure widget:
aria-expandedtoggled on the trigger.
The first rule of ARIA is to not use it. The second is to follow the WAI-ARIA Authoring Practices patterns exactly; a half-built combobox is worse than a <select>.
Keyboard navigation works or the page is broken
Every interactive control must be reachable and operable from the keyboard. Run the page with Tab, Shift+Tab, Enter, Space, Escape, and the arrow keys.
- DOM order matches visual order. Do not reorder with CSS
orderor absolute positioning and expect the tab order to follow the eye. - Add a skip link as the first focusable element:
<a href="#main">Skip to content</a>. Reveal it on focus. - Style focus with
:focus-visible, not:focus. The ring shows for keyboard users; mouse clicks stay clean. - Never trap focus outside a modal. Inside a modal, trap focus and return it to the trigger on close.
If a control needs tabindex="0", the right answer is usually a different element.
Color contrast: 4.5:1 body, 3:1 large
Run every text and UI color pair through a contrast check.
- Body text: 4.5:1 minimum against its background.
- Large text (18pt or 14pt bold and up): 3:1.
- Interactive component borders and focus rings: 3:1 against adjacent colors.
- Disabled controls have no contrast requirement, but disable rarely; an unusable affordance is worse than a missing one.
Use the DevTools color picker or axe to confirm. Do not eyeball.
Test with a screen reader
Open the site in a real reader at least once per release.
- macOS: VoiceOver. Cmd+F5 to toggle; Ctrl+Option+Arrow to navigate.
- Windows: NVDA (free). Insert+Down arrow to read continuously.
- iOS: VoiceOver; Android: TalkBack.
Run the primary flow: landing, search, form submission, error recovery. If the reader announces a button as “button button” or skips a heading, you have an aria duplication or an outline bug.
Run axe-core in CI
Add automated checks to the test pipeline. They catch the 30 to 40 percent of issues that ship anyway.
import { test, expect } from "@playwright/test"
import AxeBuilder from "@axe-core/playwright"
test("home has no a11y violations", async ({ page }) => {
await page.goto("/")
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
})Axe will not catch reading order, focus management, or cognitive complexity. Pair the lint pass with one keyboard run and one screen reader pass per major change.