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
| Element | Minimum | Note |
|---|---|---|
| Body text | 4.5:1 | Under 18.66 px, or under 24 px if not bold |
| Large text | 3:1 | From 24 px, or 18.66 px when bold |
| UI components | 3:1 | Button borders, icons, focus indicators |
| Focus indicator | 3:1 | Against 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
- Automated in CIaxe-core via Playwright or jest-axe catches roughly a third of issues and prevents regressions.
- Keyboard by handWalk the main flows without a mouse. This finds the most per minute spent.
- Screen readerNVDA on Windows or VoiceOver on Mac. You do not need to be an expert to hear that something is wrong.
- Zoom and reflow200% text zoom and 320 px width with no horizontal scrolling.
- 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