Accessibility Patterns — Copy-Paste Reference
Contents
- Skip link
- Focus styles
- Accessible form field with error
- Icon-only button
- Live regions (async updates)
- Accessible modal dialog
- Reduced motion
- Contrast tokens
- Gotchas
Skip link
First focusable element on the page; visible only on focus.
<a class="skip-link" href="#main">Skip to main content</a>
<main id="main" tabindex="-1">…</main>.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 8px;
top: 8px;
z-index: 100;
}Focus styles
:focus-visible shows the ring for keyboard users without flashing it on every mouse click.
:focus-visible {
outline: 2px solid var(--color-focus, #1a56db);
outline-offset: 2px;
}
/* never do: :focus { outline: none; } without a replacement */Accessible form field with error
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email"
aria-describedby="email-error" aria-invalid="true">
<p id="email-error" class="error">Enter an email address with an @, like name@example.com.</p>Remove aria-invalid and the error node (or empty it) once the field validates.
Icon-only button
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" focusable="false">…</svg>
</button>aria-hidden on the SVG keeps screen readers from announcing the icon twice.
Live regions (async updates)
<!-- polite: status updates, search-result counts -->
<div aria-live="polite" class="visually-hidden" id="status"></div>
<!-- assertive, errors only -->
<div role="alert" id="form-alert"></div>document.getElementById('status').textContent = '12 results found';The live region must exist in the DOM before you write into it — injecting a new aria-live node with text is not announced reliably.
Accessible modal dialog
Native <dialog> gives focus trapping and Esc for free — prefer it.
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete file?</h2>
<button type="button" id="cancel">Cancel</button>
<button type="button" id="ok">Delete</button>
</dialog>const dlg = document.getElementById('confirm');
dlg.showModal(); // traps focus, Esc closes
dlg.addEventListener('close', () => opener.focus()); // return focus to the triggerReduced motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Contrast tokens
Bake compliance into tokens so components can't ship a failing pair.
:root {
--text-on-light: #1f2937; /* 14.7:1 on #ffffff */
--text-muted: #4b5563; /* 7.6:1 on #ffffff — still AA for body text */
--color-focus: #1a56db; /* 3.6:1 vs #ffffff — passes 3:1 UI minimum */
}Check ratios with a tool (e.g., npx wcag-contrast 4b5563 ffffff) rather than by eye.
Gotchas
display: noneandvisibility: hiddenhide content from screen readers too; use a.visually-hiddenclip-pattern class for screen-reader-only text.tabindexvalues >0 break natural focus order — only ever use0and-1.role="button"on a div does NOT add Enter/Space handling; you must write the keydown handler yourself (another reason to use<button>).- Placeholder text is not a label and usually fails contrast; see designing-forms.
aria-labeloverrides visible text — if a button says "Save" butaria-label="Submit", voice-control users saying "click Save" fail (WCAG 2.5.3 label-in-name).- Automated tools (axe, Lighthouse) catch roughly a third of WCAG issues; the keyboard and screen-reader walkthroughs are not optional.