# Artifact Accessibility

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

- Skill: `lukehle/artifact-accessibility` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lukehle/artifact-accessibility`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukehle/artifact-accessibility/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Lukehle (https://skillmd.com/u/lukehle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukehle/artifact-accessibility

---


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

```html
<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:

```html
<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.

```css
: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.

```html
<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

```css
@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

- [ ] Greyscale filter — every series still distinguishable
- [ ] Tab through everything — visible focus, logical order, nothing unreachable
- [ ] `Escape` closes and clears
- [ ] Contrast checked in **both** themes, including muted text and axis ticks
- [ ] Every chart has `<title>` + `<desc>` with an actual takeaway
- [ ] A data-table fallback exists
- [ ] Reduced-motion honoured, end state correct
- [ ] 200% zoom, no body horizontal scroll
- [ ] No value available *only* on hover

---

## 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

