Accessibility — Cheat Sheet
HTML, CSS & Responsive · 5 topics. Download the PDF or the Instagram carousel and share it.
Accessible Structure — What a Screen Reader Hears
A screen reader builds its own picture of your page from the accessibility tree. Understanding what that tree contains explains why semantic markup is not a formality.
- ✓Assistive technology reads a role, name, state and value for each element — a div supplies none of them
- ✓The accessible name has a priority order, so aria-label silently overrides visible text
- ✓Users navigate by headings, landmarks, links and form fields rather than reading linearly
- ✓A skip link as the first focusable element lets keyboard users bypass navigation on every page
- ✓.sr-only hides visually but keeps content announced; aria-hidden on a focusable element creates an unusable control
<button aria-pressed="true">Bookmark</button>
// role: button name: "Bookmark" state: pressed
<input type="checkbox" id="pro" checked>
<label for="pro">Pro only</label>
// role: checkbox name: "Pro only" state: checked
<div onclick="…">Bookmark</div>
// role: generic name: (none) state: (none)
// Announced as nothing. Not reachable. Not operable.
/* Inspect it: DevTools -> Elements -> Accessibility panel shows the
computed role and name for the selected element. If the name is
empty or the role is "generic" on something interactive, that is
your bug, visible before any screen reader is involved. */
/* The accessible NAME is computed in a fixed priority order:
aria-labelledby > aria-label > the native label / content >
title attribute
So an aria-label silently overrides visible text — which is why a
button reading "Save" can announce something completely different. */
<button aria-label="Close">Save</button> // announces "Close". Bug.Keyboard Access and Focus Management
Everything doable with a mouse must be doable with a keyboard. Test it by unplugging the mouse — most sites fail within a minute.
- ✓outline: none without a replacement is the most damaging common CSS line; :focus-visible is the correct tool
- ✓A focus indicator needs about 2px and 3:1 contrast to satisfy WCAG 2.2
- ✓Only tabindex 0 and -1 are acceptable — positive values corrupt the page's tab order
- ✓Tab order follows the DOM, so visual reordering with CSS desynchronises it from what users see
- ✓When a dialog opens, content appears or a row is deleted, focus must be moved deliberately — including on SPA route changes
/* This appears in countless codebases and breaks keyboard use */
*:focus { outline: none }
/* If you remove it, you must replace it */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none } /* mouse clicks only */
/* :focus-visible applies for keyboard focus and not for mouse
clicks, which is exactly the behaviour people wanted when they
removed outlines. */
/* WCAG 2.2 requires the focus indicator to be at least 2px thick
and have 3:1 contrast against the adjacent colour. On a dark
background a dark outline is as good as none. */
/* Make it work on both light and dark: */
:focus-visible { outline: 2px solid; outline-color: currentColor }
/* or a two-tone ring that shows on any background: */
:focus-visible { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent) }
/* Also ensure the focused element is not hidden under a sticky
header when it scrolls into view: */
html { scroll-padding-block-start: 5rem }ARIA — When to Use It, and When Not To
ARIA changes what assistive technology reports without changing behaviour. That asymmetry is why incorrect ARIA is worse than none — it makes a promise the code does not keep.
- ✓ARIA changes what is announced but never adds behaviour — a role without keyboard support is a broken promise
- ✓The first rule of ARIA is to use a native element instead; pages with ARIA average more errors than pages without
- ✓aria-expanded, aria-current, aria-pressed and aria-describedby must be kept in sync, and a stale state is worse than none
- ✓ARIA attributes double as CSS selectors, which keeps styling and semantics from drifting apart
- ✓A live region must already exist in the DOM before its text changes; role="alert" interrupts and should be reserved for errors
/* 1. Use a native element if one exists.
<button> over <div role="button"> — every time. */
/* 2. Do not change native semantics without a very good reason. */
<h2 role="button"> // now it is not a heading in the outline
<button role="heading"> // now it is not in the buttons list
/* 3. Every interactive ARIA control must be keyboard operable.
A role is a promise; the key handler is keeping it. */
/* 4. Do not put aria-hidden on a focusable element — it creates a
control that can be reached but not perceived. */
/* 5. Every interactive element needs an accessible name. */
<button aria-label="Close dialog"><svg aria-hidden="true"/></button>
/* The measurable consequence: surveys of the top million sites
consistently find pages WITH ARIA average more detected errors
than pages without, because it is applied incorrectly. Reach for
it deliberately, not decoratively. */
/* If you are implementing a combobox, tree or complex menu, follow
the ARIA Authoring Practices Guide exactly — or use a headless
library that already has. These patterns have a lot of required
keyboard behaviour that is easy to get subtly wrong. */Visual Accessibility — Contrast, Zoom and Motion
Low vision, colour vision deficiency and motion sensitivity affect a large share of users. Each has a specific, testable requirement — and meeting them improves the design for everyone.
- ✓WCAG AA is 4.5:1 for body text and 3:1 for large text and UI components — DevTools shows the ratio directly
- ✓Colour must never be the only signal; pair it with an icon, text or shape
- ✓A page must work at 200% zoom, and text must scale — which px-based font sizes and fixed heights prevent
- ✓prefers-reduced-motion should reduce movement rather than remove all feedback, and long animations need a pause control
- ✓Line length around 65 characters, line-height 1.5 and left alignment materially help dyslexic and low-vision readers
/* WCAG AA — the working minimum:
body text 4.5:1
large text (>=24px, or >=18.66px bold) 3:1
UI components, borders, icons, focus rings 3:1
AAA is 7:1 for body text, worth aiming at for long reading. */
/* The usual failures:
grey placeholder text on white (#999 on #fff = 2.8:1)
white text on a mid-tone brand colour
disabled state so faint it is unreadable
text over a photograph with no scrim */
/* Check: DevTools colour picker shows the ratio and an AA/AAA badge
directly in the swatch. There is no excuse for guessing. */
/* Never rely on colour alone — about 1 in 12 men has a colour
vision deficiency, and red/green is the common one: */
<span class="error">✕ Failed — 2 tests did not pass</span>
/* icon + text + colour, so the meaning survives without the colour */
/* Charts: use pattern, label or shape in addition to hue. */
/* Test it: DevTools -> Rendering -> Emulate vision deficiencies
(protanopia, deuteranopia, blurred vision). */Testing Accessibility
Automated tools catch roughly a third of issues. The rest need a keyboard, a screen reader and about ten minutes — which is still far cheaper than the alternative.
- ✓Automated tools catch roughly a third of issues — a Lighthouse score of 100 does not mean an accessible page
- ✓axe in CI and eslint-plugin-jsx-a11y while authoring catch mechanical problems early and cheaply
- ✓The manual checklist is keyboard, 200% zoom, larger default font, CSS off, images off, and a few minutes with a screen reader
- ✓WCAG 2.2 level AA is the practical target that most legal requirements reference
- ✓Building with the correct native element from the start removes most of what remediation work consists of
# In the browser
axe DevTools extension # the most thorough, best explanations
Lighthouse -> Accessibility # a quick score, fewer checks
WAVE # visual overlay of issues
# In tests
npm i -D @axe-core/playwright
const results = await new AxeBuilder({ page }).analyze()
expect(results.violations).toEqual([])
# Component tests
import { axe } from 'jest-axe'
expect(await axe(container)).toHaveNoViolations()
# Linting, at authoring time
eslint-plugin-jsx-a11y # catches missing alt, invalid ARIA,
# click handlers without key handlers
/* What they DO catch: missing alt, low contrast, missing labels,
invalid ARIA, duplicate ids, missing lang, empty links.
What they CANNOT catch: alt="image" on a chart, a tab order that
makes no sense, a modal that does not trap focus, a "Submit"
button that deletes an account, motion that causes nausea, or
heading levels chosen for their size. */