Skip to main content
Back to the blog
Developer guide

The developer's accessibility checklist

This checklist is written for developers shipping against WCAG 2.2 AA. It covers markup, keyboard, forms, dynamic content and testing — with concrete examples.

Alnet12 min read

The vast majority of accessibility defects are not exotic. They are the same ten things, over and over. Here they are, sorted by where they occur in the code.

1. Semantic markup

The right element gives you role, state and keyboard handling for free. The wrong element means you have to build all of it yourself — and you will usually build it incompletely.

<!-- No: no role, no keyboard support, no focus -->
<div class="btn" onclick="submit()">Submit</div>

<!-- Yes: works with keyboard, screen reader and forms -->
<button type="submit">Submit</button>
  • One <h1> per page, and never skip levels going down
  • Use <nav>, <main>, <header>, <footer> and <aside> as landmarks
  • Lists should be <ul>/<ol>, not <div>s with bullet icons
  • <button> for actions, <a href> for navigation — never the reverse

2. Keyboard navigation

Test this early: put the mouse down and move through the page with Tab, Shift+Tab, Enter, Space and Escape. Everything you can do with a mouse must be possible this way.

/* No: removes focus indication for everyone */
:focus { outline: none; }

/* Yes: hides it only for mouse users, and makes it obvious */
:focus-visible {
  outline: 2px solid var(--color-brand-600);
  outline-offset: 2px;
}
  • Focus order must follow the visual order
  • Modals should trap focus and return it to the trigger on close
  • Escape should close anything opened over the content
  • Never use positive tabindex values

3. Forms

<label for="email">Email</label>
<input
  id="email"
  name="email"
  type="email"
  autocomplete="email"
  aria-describedby="email-error"
  aria-invalid="true"
/>
<p id="email-error" role="alert">Enter a valid email address.</p>
  • Every field needs a visible <label> connected with for/id
  • A placeholder is not a substitute for a label
  • Error messages must be tied to the field with aria-describedby
  • Set autocomplete on fields asking for the user's own details
  • Do not clear filled fields when validation fails

4. Images and media

<!-- Informative: describe the content -->
<img src="/chart.png" alt="Revenue grew from 4 to 6.2 million in 2025" />

<!-- Decorative: empty alt so screen readers skip it -->
<img src="/pattern.svg" alt="" />

5. Colour and contrast

ElementMinimumNote
Body text4.5:1Under 18.66 px, or under 24 px if not bold
Large text3:1From 24 px, or 18.66 px when bold
UI components3:1Button borders, icons, focus indicators
Focus indicator3:1Against the background it appears on

Colour alone must never be the only carrier of information. A red border around a field has to be accompanied by text explaining the error.

6. Dynamic content

<!-- Status messages that should not interrupt -->
<div role="status" aria-live="polite">3 results</div>

<!-- Critical errors that must be announced immediately -->
<div role="alert">The payment was declined.</div>
  • Move focus to new content when the user triggers navigation in an SPA
  • Update the document title on route change
  • Use aria-live sparingly — too much noise is worse than too little
  • Announce loading and error states, do not just show a spinner

7. Testing in practice

  1. Automated in CIaxe-core via Playwright or jest-axe catches roughly a third of issues and prevents regressions.
  2. Keyboard by handWalk the main flows without a mouse. This finds the most per minute spent.
  3. Screen readerNVDA on Windows or VoiceOver on Mac. You do not need to be an expert to hear that something is wrong.
  4. Zoom and reflow200% text zoom and 320 px width with no horizontal scrolling.
  5. Reduced motionCheck that prefers-reduced-motion actually turns your animations off.
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("the front page has no serious violations", async ({ page }) => {
  await page.goto("/");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(results.violations).toEqual([]);
});
  • #Development
  • #WCAG
  • #HTML
  • #ARIA
  • #Testing