Patterns / Accessibility
Build it right once, and it works for everyone.
One in six people lives with a disability, and the same work that serves them serves everyone: keyboard users, glare on the screen, a cracked phone, a noisy room. Our floor is WCAG 2.2 Level AA on every surface, and Level AAA wherever a pattern reaches it without hurting the experience. This page is the taxonomy: the four principles, the order of operations, and the checks that keep the floor level.
The Four Pillars: POUR
Every WCAG success criterion hangs off four principles. Content has to be Perceivable, Operable, Understandable, and Robust. Miss one pillar and the interface locks out a real person, not an audit.
Perceivable
Reaches every sense. Text alternatives for images, captions for audio, contrast that survives low vision, and never color alone.
SC 1.1 to 1.4Operable
Works from a keyboard. Nothing traps focus, targets are big enough to hit, and motion can be turned down.
SC 2.1 to 2.5Understandable
Reads clearly and behaves predictably. Labels stay put, errors explain themselves, and focus never surprises.
SC 3.1 to 3.3Robust
Valid markup, and every control exposes a name, a role, and a value that assistive technology can read.
SC 4.1Semantic HTML Before ARIA
The first rule of ARIA is: do not use ARIA. Reach for the native element first, because it hands you the accessibility that a styled <div> makes you rebuild from scratch.
<button>, <nav>, or <a href> ships with a role, an accessible name, keyboard behavior, and focus already wired in. ARIA changes only what a screen reader announces, so anything you build on a bare <div> you also have to make operable and focusable by hand.<button type="button">Buy tickets</button>Role, accessible name, focus stop, and keyboard activation come for free, and it behaves the same in every browser and assistive tech.
<div onclick="buy()">Buy tickets</div>No role, no keyboard, no focus stop. You would have to rebuild tabindex, key handling, and the focus ring by hand, and it is easy to miss one.
- Prefer the native element whenever it carries the semantics and behavior you need. A real
<button>beats<div role="button" tabindex="0">. - Keep native semantics intact: nest a real element inside rather than repainting one with a role.
- Keep every interactive ARIA control keyboard operable. Add
role="button"and you ownEnter,Space, focus, and the ring. - Keep focusable elements visible to assistive tech:
aria-hidden="true"androle="presentation"belong only on content that cannot receive focus. - Give every interactive element an accessible name: a visible
<label>, its text,aria-label, oraria-labelledby.
No ARIA is better than bad ARIA. Unsure? Drop to semantic HTML and you will almost always be more accessible, not less.
Contrast, Color, and Dark Mode
Contrast is the most-failed criterion on the web, and the easiest to fix. SC 1.4.3 asks 4.5:1 on text and 3:1 on large text (18pt, or 14pt bold); SC 1.4.11 asks 3:1 on interactive edges and meaningful graphics. And SC 1.4.1 means color is never the only signal: an error is red and carries an icon and text.
Every brand pairing clears its floor, most with AAA headroom:
Dark mode is not a reskin. Both themes are tuned by hand, both clear the floor, and both are wired to prefers-color-scheme.
Keyboard and Focus
Many people navigate entirely by keyboard: a motor impairment, repetitive strain, a screen reader, or plain preference. SC 2.1.1 Keyboard asks that every control be reachable and operable from the keyboard, and the activation key follows the role: Tab moves focus, Space activates buttons and checkboxes, and Enter activates links and buttons. SC 2.1.2 No Keyboard Trap is a separate promise: focus that lands somewhere can always move away again through a standard mechanism, not necessarily Escape. Escape-to-close is a dialog and menu convention from the ARIA Authoring Practices Guide, not part of 2.1.1. Focus stays visible (SC 2.4.7) and is never hidden behind sticky UI (SC 2.4.11).
Skip links let a keyboard user jump straight to <main> instead of tabbing through the whole nav. And .sr-only keeps a label in the accessibility tree while hiding it on screen.
Target Size and Motion
A control has to be big enough to hit. SC 2.5.8 sets a floor of 24 by 24 CSS pixels; we aim for 44 by 44, the size Apple and WCAG AAA both recommend. Motion is the other half of Operable: honor prefers-reduced-motion as a floor, collapse the motion, keep the meaning (SC 2.3.3, SC 2.2.2).
Screen Readers: Name, Role, and Value
A screen reader reads three things off every control, and SC 4.1.2 requires all three. A native <input type="checkbox"> with a label supplies them for free. A <div> styled to look like one supplies none, so a custom widget must set each by hand and keep the value in sync.
Name
What it is called. "Search", "Buy tickets", "Close dialog".
Role
What kind of thing it is. Button, checkbox, tab, dialog.
Value / State
Where it stands now. Checked, expanded, "3 of 10".
Live regions announce changes away from focus: a cart count, a validation error, results loading. Use role="alert" to interrupt, and role="status" or aria-live="polite" to wait for a pause.
Before You Ship
Run this before every PR. It catches most issues in a few minutes, and each item names the criterion it protects.
- Done: Semantic HTML elements (button, nav, main, header, article)SC 1.3.1
- Done: Color contrast: 4.5:1 normal text, 3:1 large text and UISC 1.4.3 / 1.4.11
- Done: Visible focus, never obscured behind sticky UISC 2.4.7 / 2.4.11
- Done: Accessible name on every control, including icon-only buttonsSC 4.1.2
- Done: Labels connected to form inputs (for/id)SC 3.3.2
- Done: Touch targets: 24x24px floor (AA), 44x44px target (AAA)SC 2.5.8
- Done: Live regions announce dynamic status messagesSC 4.1.3
- Done: prefers-reduced-motion respectedSC 2.3.3
- Done: Keyboard: Tab reaches every control, Space and Enter activate by role, focus can always move awaySC 2.1.1 / 2.1.2
- Done: Screen reader pass (VoiceOver or NVDA)SC 4.1.2
Code Reference
Copy these patterns into your codebase. All helpers are designed to be added to main.css.
Focus States & Motion Preferences
/* Global focus ring - applies to all focusable elements */ :focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } /* Custom focus ring utility */ .focus-ring:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.15); } /* Respect motion preferences globally */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } /* Manual motion disable (for JS toggle) */ .no-motion, .no-motion * { animation: none !important; transition: none !important; } /* Dark mode - automatic based on system preference */ @media (prefers-color-scheme: dark) { :root { --color-text-heading: #f1f5f9; --color-text-body: #cbd5e1; --color-surface: #1e293b; --color-background: #0f172a; } } /* Manual dark mode toggle (via class or data attr) */ .dark, [data-theme="dark"] { --color-text-heading: #f1f5f9; --color-text-body: #cbd5e1; /* ... all dark tokens ... */ }
Skip Links & Screen Reader Utilities
/* Skip link - hidden until focused */ .skip-link { position: absolute; top: -100%; left: 1rem; padding: 1rem 1.5rem; background: var(--color-primary); color: white; border-radius: var(--radius-md); font-weight: 600; text-decoration: none; z-index: 9999; } .skip-link:focus { top: 1rem; } /* Screen reader only - visually hidden but accessible */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Touch target - ensures minimum clickable area */ .touch-target { position: relative; } .touch-target::after { content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-width: 44px; min-height: 44px; }
ARIA Live Regions
<!-- Error - interrupts immediately --> <div role="alert"> Payment failed. Please try again. </div> <!-- Status - waits for pause in speech --> <div role="status"> 3 items in cart </div> <!-- Loading state --> <div role="status" aria-busy="true"> Loading events... </div> <!-- Search results - announces full content --> <div aria-live="polite" aria-atomic="true"> Showing 24 events </div>
Accessible Forms
<div class="form-field"> <label for="email"> Email <span aria-hidden="true">*</span> <span class="sr-only">(required)</span> </label> <input type="email" id="email" autocomplete="email" aria-required="true" aria-describedby="email-error" /> <p id="email-error" role="alert" hidden> Enter a valid email </p> </div>
Accessible Modals
<!-- Modal dialog: traps focus, closes on Escape, returns focus --> <div role="dialog" aria-modal="true" aria-labelledby="modal-title" > <h2 id="modal-title">Confirm Purchase</h2> <p>Buy 2 tickets for $150?</p> <button>Cancel</button> <button autofocus>Confirm</button> </div> <!-- Background becomes inert --> <div id="app" inert>...</div>
Sources
WCAG 2.2 success criteria: contrast SC 1.4.3 / 1.4.11, use of color 1.4.1, keyboard 2.1.1 / 2.1.2, focus 2.4.7 / 2.4.11 / 2.4.13, target size 2.5.8 / 2.5.5, motion 2.3.3 / 2.2.2, name-role-value 4.1.2, status messages 4.1.3 (w3.org/TR/WCAG22). POUR principles and the five rules of ARIA from W3C WAI and Using ARIA (w3.org/WAI, w3.org/TR/using-aria); widget patterns from the ARIA Authoring Practices Guide (w3.org/WAI/ARIA/apg). The one-in-six figure is WHO (who.int); the 44px touch target follows the Apple Human Interface Guidelines. Ratios use the WCAG relative-luminance formula, matching the Color Picker story.