Skip to main content

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/.

ContentAAAAA
Normal text4.5:17:1
Large text (18pt / 14pt bold and up)3:14.5:1
UI components and graphical objects3: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 relative luminance and contrast ratio
// 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>);
Output
Alpha is not accounted for

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.

Roving tabindex tablist
// 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.

aria-expanded, aria-pressed, aria-checked, aria-live
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 />);
The live-region trap

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.

Accessible field wiring vs. the common mistake
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.

Heading outline validator
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>);
Output

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:

ConcernWhy a sandbox can't cover itWhere to go
Screen-reader announcementsDepends on the reader, the browser, and the user's verbosity settingsTesting & Tools
Focus trapping in real dialogsThe app's dialogs are MUI Dialog/Drawer, which manage focus themselvesFocus Management
Automated rule scanningNeeds axe running against a rendered pageTesting & Tools, plus @storybook/addon-a11y, which is already installed
Lint-level enforcementRuns in CI, not in a pageESLint Ruleseslint-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.


External tools