The Accessibility Lab
The rest of this section explains the rules. This page is where you can run them.
Everything below is self-contained: pure WCAG maths, or plain HTML and ARIA that behaves the same in a doc page as it does in the app. Nothing here is a mock-up of an Alert.CA component — for how the real components implement these patterns, follow the links at the bottom of each section.
Contrast ratio
WCAG contrast is a closed-form calculation on two colours: convert each channel to linear light,
take the weighted relative luminance, and compare. There is no judgement involved, which makes it
the single easiest accessibility rule to automate — and the easiest to check for a proposed token
before it ever lands in styles/tokens/.
| Content | AA | AAA |
|---|---|---|
| Normal text | 4.5:1 | 7:1 |
| Large text (18pt / 14pt bold and up) | 3:1 | 4.5:1 |
| UI components and graphical objects | 3:1 | — |
The sandbox below is seeded with real pairs from this repo's tokens. Swap in a colour you are considering and see whether it clears the bar before you open a PR.
// WCAG 2.x relative luminance + contrast ratio. // Reference: https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html function toRgb(color) { if (color.charAt(0) === '#') { let hex = color.slice(1); if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join(''); return [ parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), ]; } const nums = color.replace(/[^0-9.,]/g, '').split(',').map(Number); return [nums[0], nums[1], nums[2]]; } function relativeLuminance(color) { const channels = toRgb(color).map(function (v) { const c = v / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; } function contrastRatio(fg, bg) { const a = relativeLuminance(fg); const b = relativeLuminance(bg); const lighter = Math.max(a, b); const darker = Math.min(a, b); return (lighter + 0.05) / (darker + 0.05); } function verdict(ratio) { const marks = []; marks.push((ratio >= 4.5 ? 'PASS' : 'FAIL') + ' AA text'); marks.push((ratio >= 7 ? 'PASS' : 'FAIL') + ' AAA text'); marks.push((ratio >= 3 ? 'PASS' : 'FAIL') + ' AA large / UI'); return marks.join(' '); } // --- Real token pairs from styles/tokens/ — edit or add your own ----------- const pairs = [ { name: 'dark: primary text on default bg', fg: 'rgba(255,255,255,1)', bg: '#000000' }, { name: 'dark: primary text on paper bg', fg: 'rgba(255,255,255,1)', bg: '#353535' }, { name: 'dark: disabled text on paper bg', fg: '#9e9e9e', bg: '#353535' }, { name: 'light: primary text on default bg', fg: 'rgba(0,0,0,0.87)', bg: '#f0ebe1' }, { name: 'light: secondary text on paper bg', fg: 'rgba(0,0,0,0.6)', bg: '#e6e0d4' }, { name: 'button primary text on button base', fg: '#49454f', bg: '#ffcd00' }, ]; const rows = pairs.map(function (p) { const ratio = contrastRatio(p.fg, p.bg); return p.name.padEnd(38) + ratio.toFixed(2).padStart(6) + ':1 ' + verdict(ratio); }); render(<pre style={{margin: 0}}>{rows.join('\n')}</pre>);
Several text tokens are expressed with an alpha channel (rgba(0, 0, 0, 0.87)). The calculation
above — like most quick contrast checkers — reads only the RGB triplet and ignores the alpha, so it
reports the ratio for the fully opaque colour. The real on-screen colour is that value composited
over whatever is behind it, and its true ratio is lower. When a token uses alpha, composite it
against the actual background first, or treat the number here as an optimistic upper bound.
See Color & Contrast for the full guidance, and Design Tokens for where these values are defined.
Roving tabindex
A group of related controls — a tablist, a toolbar, a menu — should be one tab stop, not one
per item. The pattern is called roving tabindex: exactly one item has tabIndex={0} at a time,
every other item has tabIndex={-1}, and the arrow keys move both the selection and the focus.
Tab into the tablist below, then use the arrow keys. Note that Tab leaves the group entirely rather than walking through the remaining tabs.
// WAI-ARIA tabs pattern. One tab stop for the whole group; // arrow keys move selection and focus together. function Tabs() { const labels = ['Cameras', 'Alerts', 'Settings']; const [selected, setSelected] = React.useState(0); const refs = React.useRef([]); function focusTab(index) { setSelected(index); const node = refs.current[index]; if (node) node.focus(); } function onKeyDown(event) { if (event.key === 'ArrowRight') { event.preventDefault(); focusTab((selected + 1) % labels.length); } else if (event.key === 'ArrowLeft') { event.preventDefault(); focusTab((selected - 1 + labels.length) % labels.length); } else if (event.key === 'Home') { event.preventDefault(); focusTab(0); } else if (event.key === 'End') { event.preventDefault(); focusTab(labels.length - 1); } } return ( <div> <div role="tablist" aria-label="Example tabs" onKeyDown={onKeyDown}> {labels.map(function (label, i) { return ( <button key={label} ref={function (node) { refs.current[i] = node; }} id={'demo-tab-' + i} role="tab" aria-selected={selected === i} aria-controls={'demo-panel-' + i} tabIndex={selected === i ? 0 : -1} style={{ padding: '8px 16px', marginRight: 4, cursor: 'pointer', fontWeight: selected === i ? 700 : 400, }} > {label} </button> ); })} </div> <div id={'demo-panel-' + selected} role="tabpanel" aria-labelledby={'demo-tab-' + selected} tabIndex={0} style={{ padding: 16, border: '1px solid currentColor', marginTop: 8 }} > Panel content for {labels[selected]}. Only the selected tab is a tab stop. </div> </div> ); } render(<Tabs />);
Three details that are easy to drop and hard to notice: aria-controls on each tab, aria-labelledby
on the panel pointing back at its tab, and tabIndex={0} on the panel so keyboard users can
reach its content when it is not itself focusable. More in
Keyboard Navigation and Focus Management.
ARIA state attributes
Custom controls have to say out loud what a native control communicates for free. Each example below shows the attribute changing as you interact — turn on a screen reader and the announcements change with it.
function AriaStates() { const [expanded, setExpanded] = React.useState(false); const [pressed, setPressed] = React.useState(false); const [checked, setChecked] = React.useState(false); const [message, setMessage] = React.useState(''); const box = { padding: 12, border: '1px solid currentColor', marginBottom: 12 }; return ( <div> {/* aria-expanded — the button owns the state, not the panel */} <div style={box}> <button aria-expanded={expanded} aria-controls="aria-demo-panel" onClick={function () { setExpanded(!expanded); }} > {expanded ? 'Collapse' : 'Expand'} details </button> {expanded && <p id="aria-demo-panel">Now announced as expanded.</p>} </div> {/* aria-pressed — a toggle button, NOT a checkbox */} <div style={box}> <button aria-pressed={pressed} onClick={function () { setPressed(!pressed); }}> Notifications {pressed ? 'on' : 'off'} </button> <div>aria-pressed = {String(pressed)}</div> </div> {/* role=checkbox — you must supply keyboard support yourself */} <div style={box}> <span role="checkbox" aria-checked={checked} tabIndex={0} onClick={function () { setChecked(!checked); }} onKeyDown={function (e) { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setChecked(!checked); } }} style={{ cursor: 'pointer', textDecoration: 'underline' }} > {checked ? '[x]' : '[ ]'} Custom checkbox (Space or Enter) </span> </div> {/* aria-live — the region must exist BEFORE the message arrives */} <div style={box}> <button onClick={function () { setMessage('Settings saved at ' + new Date().toLocaleTimeString()); }}> Save </button> <div role="status" aria-live="polite" aria-atomic="true" style={{ minHeight: 24 }}> {message} </div> </div> </div> ); } render(<AriaStates />);
An aria-live region only announces changes to a node that was already in the accessibility
tree. Rendering the container and its first message in the same commit usually announces nothing.
Mount the empty region up front — as above, with a minHeight so it does not shift layout — and
write text into it later. The same applies to role="alert", which is aria-live="assertive" with
an implicit atomic update.
Full reference: ARIA Roles & Attributes.
Wiring a field to its label, hint, and error
The single most common accessibility defect in a form is an input whose helper text and error message are visible but not associated — a sighted user sees "Email is required" under the box; a screen-reader user hears nothing.
aria-describedby takes a space-separated list of ids, so a field can point at both its hint
and its error at once. The example below renders the same field twice so you can compare.
function FieldWiring() { const [value, setValue] = React.useState(''); const [touched, setTouched] = React.useState(false); const invalid = touched && value.length === 0; const wrap = { padding: 12, border: '1px solid currentColor', marginBottom: 16 }; const input = { display: 'block', padding: 8, marginTop: 4, width: '100%', boxSizing: 'border-box' }; return ( <div> <div style={wrap}> <strong>Correct</strong> <label htmlFor="good-email" style={{ display: 'block', marginTop: 8 }}> Email address <span aria-hidden="true">*</span> </label> <input id="good-email" type="email" value={value} required aria-required="true" aria-invalid={invalid} aria-describedby={invalid ? 'good-hint good-error' : 'good-hint'} onChange={function (e) { setValue(e.target.value); }} onBlur={function () { setTouched(true); }} style={input} /> <p id="good-hint" style={{ fontSize: 13 }}>We only use this for alert delivery.</p> {invalid && <p id="good-error" role="alert" style={{ fontSize: 13 }}>Email address is required.</p>} </div> <div style={wrap}> <strong>Wrong — visually identical, silent to assistive tech</strong> <input type="email" placeholder="Email address" style={input} /> <p style={{ fontSize: 13 }}>We only use this for alert delivery.</p> <p style={{ fontSize: 13 }}>Email address is required.</p> </div> </div> ); } render(<FieldWiring />);
What makes the second one fail: a placeholder is not a label (it disappears on input and many
screen readers skip it), the hint and error are unassociated text nodes, required is not
communicated, and there is no role="alert" so the error is never announced when it appears. Note
also the aria-hidden="true" on the asterisk in the correct version — otherwise it is read out
literally as "asterisk" on top of the aria-required announcement.
See Forms & Labels for the complete pattern set.
Heading outline
Screen-reader users navigate by heading. A skipped level is not a style problem — it is a hole in the document's navigation. The rule is small enough to check mechanically.
function analyseHeadings(headings) { const issues = []; let h1Count = 0; let prevLevel = 0; headings.forEach(function (h) { if (h.level === 1) h1Count++; if (prevLevel !== 0 && h.level > prevLevel + 1) { issues.push('Skipped level: h' + prevLevel + ' -> h' + h.level + ' at "' + h.text + '"'); } prevLevel = h.level; }); if (h1Count === 0) issues.push('No h1 — every page needs exactly one'); if (h1Count > 1) issues.push('Multiple h1 elements (' + h1Count + ') — should be exactly one'); return issues; } // --- Inputs: edit these --------------------------------------------------- const headings = [ { level: 1, text: 'Camera Dashboard' }, { level: 2, text: 'Active Cameras' }, { level: 3, text: 'Highway 101' }, { level: 2, text: 'Alerts' }, { level: 4, text: 'Critical Alerts' }, // skips h3 { level: 3, text: 'Warnings' }, // going back up a level is fine ]; const outline = headings.map(function (h) { return ' '.repeat(h.level - 1) + 'h' + h.level + ' ' + h.text; }); const issues = analyseHeadings(headings); render(<pre style={{margin: 0}}>{ outline.join('\n') + '\n\n' + (issues.length === 0 ? 'No issues.' : issues.length + ' issue(s):\n- ' + issues.join('\n- ')) }</pre>);
Going back up more than one level (h4 → h2) is legal — it closes sections. Only going down more than one level at a time is a violation. More in Semantic HTML.
What this page deliberately cannot test
Some things have no honest sandbox, and a demo that pretends otherwise teaches false confidence:
| Concern | Why a sandbox can't cover it | Where to go |
|---|---|---|
| Screen-reader announcements | Depends on the reader, the browser, and the user's verbosity settings | Testing & Tools |
| Focus trapping in real dialogs | The app's dialogs are MUI Dialog/Drawer, which manage focus themselves | Focus Management |
| Automated rule scanning | Needs axe running against a rendered page | Testing & Tools, plus @storybook/addon-a11y, which is already installed |
| Lint-level enforcement | Runs in CI, not in a page | ESLint Rules — eslint-plugin-jsx-a11y is configured in eslint.config.js, and npm run lint:a11y runs a focused subset |
There is no "accessibility score" anywhere on this page on purpose. Percentage scores over a handful of toggles imply a precision that neither WCAG nor any real audit provides — use the Checklist instead, which maps to actual success criteria.
Related
- Overview — principles and WCAG conformance targets
- Color & Contrast — the full contrast guidance
- Keyboard Navigation — key handling patterns
- Focus Management — focus order, traps, and restoration
- ARIA Roles & Attributes — complete ARIA reference
- Forms & Labels — accessible form patterns
- Semantic HTML — landmarks and heading structure
- Testing & Tools — axe, Storybook a11y, manual passes
- Checklist — the pre-merge pass
- Writing docs — how to add a sandbox of your own