Artifact accessibility
Two framings, both true. The compliance one: some viewers cannot perceive colour differences, cannot
use a mouse, or use a screen reader. The practical one: every accessibility fix here also makes the
page work in print, in greyscale, on a projector, and on a phone — which is where board packs
actually get read.
Never encode by colour alone
The rule that catches the most real failures. Around 8% of men have a colour vision deficiency, and
every printed deck is greyscale.
| Mark |
Second encoding |
| Lines |
Dash pattern, and a direct end-of-line label |
| Bars |
Position, direct value labels, hatch/texture for a special category |
| Areas |
Direct labels; ordering that matches the legend |
| Scatter |
Shape as well as colour |
| Heatmap |
Lightness ramp (already ordinal) plus values in cells |
| Up/down deltas |
A glyph (▲▼) or the sign — colour reinforces only |
The greyscale test: apply filter: grayscale(1) to the page and read it. If two series become
indistinguishable, add an encoding. Do this before shipping, not after someone prints it.
Prefer blue/orange over red/green wherever the semantics allow. It survives every common form of
colour blindness.
Contrast
| Element |
Minimum |
| Body text |
4.5:1 |
| Large text (18px+, or 14px bold) |
3:1 |
| Chart marks, axis lines, focus rings, control borders |
3:1 |
| Decorative only |
none, but ask why it is there |
The commonly missed ones: muted secondary text (--ink-muted often lands around 3.5:1 and needs
darkening), axis tick labels, and placeholder text. Check contrast in both themes — a
palette that passes in light frequently fails in dark, especially for muted roles.
Adjacent categorical series also need to differ from each other, not just from the background,
in both hue and lightness.
Charts and screen readers
An SVG chart is invisible to a screen reader unless you give it structure.
<figure role="group" aria-labelledby="c1-title" aria-describedby="c1-desc">
<svg role="img" aria-labelledby="c1-title c1-desc" viewBox="0 0 720 360">
<title id="c1-title">Net new ARR by month, FY26</title>
<desc id="c1-desc">
Bar chart. Ranges from $118K in January to $255K in March.
Down 27.6% versus plan in July at $210K.
</desc>
…
</svg>
</figure>
<title> names it; <desc> states the takeaway plus the range. A description that says "a bar
chart showing data" is worse than none — it costs time and delivers nothing.
- Mark decorative SVG
aria-hidden="true" (sparklines beside a value, icons). Otherwise every
one gets announced.
- Provide the data as a table. The most reliable fallback there is, and it also serves print,
Ctrl+F, and copy-paste:
<details class="chart-data">
<summary>View as table</summary>
<table> … </table>
</details>
That <details> block is often the single highest-value accessibility addition to a dashboard, and
it takes five minutes.
Keyboard
Everything interactive must be reachable and operable without a mouse.
- Use real
<button> and <a> elements. A <div> with a click handler is not focusable, not
announced, and not activatable by keyboard. If you must, it needs tabindex="0", role, and
Enter/Space handling — which is just reimplementing <button> badly.
- Visible focus ring, at 3:1 contrast. Never
outline: none without a replacement.
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
- Roving tabindex for groups. A 20-item legend should be one tab stop with arrow navigation
inside, not 20 tab stops.
Escape closes and clears — dialogs, tooltips, selections, drill-down.
- Logical tab order follows visual order. If they diverge, the layout is fighting the DOM.
For a chart, the pragmatic pattern: make the chart container one tab stop, then arrow keys move
between marks with the current value announced via a live region.
<div aria-live="polite" class="sr-only" id="readout"></div>
Focus management
- Opening a panel or dialog moves focus into it; closing returns focus to the trigger.
- Content inserted asynchronously must not steal focus.
- After a filter changes results, announce the outcome in a polite live region — "42 rows shown" —
so a screen-reader user knows something happened.
- Do not trap focus except in a modal, and there always provide
Escape.
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;
}
}
Reduced motion means no motion, not slower motion — vestibular triggers are about movement, not
speed. The end state must still be correct with all transitions removed. See
motion-and-transitions.
Structure and semantics
- One
<h1>, then a sensible heading order. Do not skip levels for styling — that is what CSS
is for.
- Landmarks:
<main>, <nav>, <aside> so a screen-reader user can jump.
- Real tables with
<th scope="col"> / <th scope="row"> and a <caption>. A grid of <div>s
is unnavigable — a screen reader cannot announce "row 4, Professional fees, $178K".
- Label every control. A
<label for> or aria-label; a placeholder is not a label and
disappears on input.
Minimums that also help everyone
- Body text no smaller than 13px; 11px only for footnotes and axis ticks.
- Touch targets at least 24×24px, ideally 44×44.
- Page usable at 200% zoom without horizontal scrolling of the body.
- Line length 60-75 characters.
Verification pass
Related skills
design-tokens — validating the palette
artifact-theming — contrast across theme states, print and greyscale
app-interaction-patterns — keyboard expectations
motion-and-transitions — reduced motion
financial-tables — accessible table structure
1---2name: artifact-accessibility3description: Make charts and data apps usable by everyone - colourblind-safe and greyscale-safe encoding, contrast, keyboard operation, focus management, screen-reader structure for SVG charts, data-table fallbacks, and reduced motion. Trigger on "accessibility", "a11y", "colorblind", "screen reader", "keyboard navigation", "contrast", "ARIA", "can everyone read this", "greyscale", "prefers-reduced-motion".4---56# Artifact accessibility78Two framings, both true. The compliance one: some viewers cannot perceive colour differences, cannot9use a mouse, or use a screen reader. The practical one: **every accessibility fix here also makes the10page work in print, in greyscale, on a projector, and on a phone** — which is where board packs11actually get read.1213---1415## Never encode by colour alone1617The rule that catches the most real failures. Around 8% of men have a colour vision deficiency, and18every printed deck is greyscale.1920| Mark | Second encoding |21|---|---|22| Lines | Dash pattern, and a direct end-of-line label |23| Bars | Position, direct value labels, hatch/texture for a special category |24| Areas | Direct labels; ordering that matches the legend |25| Scatter | Shape as well as colour |26| Heatmap | Lightness ramp (already ordinal) plus values in cells |27| Up/down deltas | A glyph (▲▼) or the sign — colour reinforces only |2829**The greyscale test:** apply `filter: grayscale(1)` to the page and read it. If two series become30indistinguishable, add an encoding. Do this before shipping, not after someone prints it.3132Prefer **blue/orange** over red/green wherever the semantics allow. It survives every common form of33colour blindness.3435---3637## Contrast3839| Element | Minimum |40|---|---|41| Body text | 4.5:1 |42| Large text (18px+, or 14px bold) | 3:1 |43| **Chart marks, axis lines, focus rings, control borders** | **3:1** |44| Decorative only | none, but ask why it is there |4546The commonly missed ones: **muted secondary text** (`--ink-muted` often lands around 3.5:1 and needs47darkening), **axis tick labels**, and **placeholder text**. Check contrast in **both themes** — a48palette that passes in light frequently fails in dark, especially for muted roles.4950Adjacent categorical series also need to differ from *each other*, not just from the background,51in both hue and lightness.5253---5455## Charts and screen readers5657An SVG chart is invisible to a screen reader unless you give it structure.5859```html60<figure role="group" aria-labelledby="c1-title" aria-describedby="c1-desc">61 <svg role="img" aria-labelledby="c1-title c1-desc" viewBox="0 0 720 360">62 <title id="c1-title">Net new ARR by month, FY26</title>63 <desc id="c1-desc">64 Bar chart. Ranges from $118K in January to $255K in March.65 Down 27.6% versus plan in July at $210K.66 </desc>67 …68 </svg>69</figure>70```7172- **`<title>` names it; `<desc>` states the takeaway plus the range.** A description that says "a bar73 chart showing data" is worse than none — it costs time and delivers nothing.74- **Mark decorative SVG `aria-hidden="true"`** (sparklines beside a value, icons). Otherwise every75 one gets announced.76- **Provide the data as a table.** The most reliable fallback there is, and it also serves print,77 Ctrl+F, and copy-paste:7879```html80<details class="chart-data">81 <summary>View as table</summary>82 <table> … </table>83</details>84```8586That `<details>` block is often the single highest-value accessibility addition to a dashboard, and87it takes five minutes.8889---9091## Keyboard9293Everything interactive must be reachable and operable without a mouse.9495- **Use real `<button>` and `<a>` elements.** A `<div>` with a click handler is not focusable, not96 announced, and not activatable by keyboard. If you must, it needs `tabindex="0"`, `role`, and97 `Enter`/`Space` handling — which is just reimplementing `<button>` badly.98- **Visible focus ring**, at 3:1 contrast. Never `outline: none` without a replacement.99100```css101:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }102```103104- **Roving tabindex for groups.** A 20-item legend should be one tab stop with arrow navigation105 inside, not 20 tab stops.106- **`Escape` closes and clears** — dialogs, tooltips, selections, drill-down.107- **Logical tab order** follows visual order. If they diverge, the layout is fighting the DOM.108109For a chart, the pragmatic pattern: make the chart container one tab stop, then arrow keys move110between marks with the current value announced via a live region.111112```html113<div aria-live="polite" class="sr-only" id="readout"></div>114```115116---117118## Focus management119120- Opening a panel or dialog moves focus **into** it; closing returns focus to the trigger.121- Content inserted asynchronously must not steal focus.122- After a filter changes results, announce the outcome in a polite live region — "42 rows shown" —123 so a screen-reader user knows something happened.124- Do not trap focus except in a modal, and there always provide `Escape`.125126---127128## Motion129130```css131@media (prefers-reduced-motion: reduce) {132 *, *::before, *::after {133 animation-duration: 0.01ms !important;134 animation-iteration-count: 1 !important;135 transition-duration: 0.01ms !important;136 scroll-behavior: auto !important;137 }138}139```140141Reduced motion means **no motion, not slower motion** — vestibular triggers are about movement, not142speed. The end state must still be correct with all transitions removed. See143`motion-and-transitions`.144145---146147## Structure and semantics148149- **One `<h1>`**, then a sensible heading order. Do not skip levels for styling — that is what CSS150 is for.151- **Landmarks**: `<main>`, `<nav>`, `<aside>` so a screen-reader user can jump.152- **Real tables** with `<th scope="col">` / `<th scope="row">` and a `<caption>`. A grid of `<div>`s153 is unnavigable — a screen reader cannot announce "row 4, Professional fees, $178K".154- **Label every control.** A `<label for>` or `aria-label`; a placeholder is not a label and155 disappears on input.156157---158159## Minimums that also help everyone160161- Body text no smaller than **13px**; 11px only for footnotes and axis ticks.162- Touch targets **at least 24×24px**, ideally 44×44.163- Page usable at **200% zoom** without horizontal scrolling of the body.164- Line length 60-75 characters.165166---167168## Verification pass169170- [ ] Greyscale filter — every series still distinguishable171- [ ] Tab through everything — visible focus, logical order, nothing unreachable172- [ ] `Escape` closes and clears173- [ ] Contrast checked in **both** themes, including muted text and axis ticks174- [ ] Every chart has `<title>` + `<desc>` with an actual takeaway175- [ ] A data-table fallback exists176- [ ] Reduced-motion honoured, end state correct177- [ ] 200% zoom, no body horizontal scroll178- [ ] No value available *only* on hover179180---181182## Related skills183184- `design-tokens` — validating the palette185- `artifact-theming` — contrast across theme states, print and greyscale186- `app-interaction-patterns` — keyboard expectations187- `motion-and-transitions` — reduced motion188- `financial-tables` — accessible table structure