# Accessibility (/docs/accessibility)

Accessibility isn't a setting in microcharts. Every chart is accessible the moment you render it, because the accessible
name is generated from your data — there's no `alt` text to forget and nothing to drift out of sync when the numbers
change.

## Summaries that write themselves

By default a chart is an `img` whose accessible name is a natural-language sentence built from the series. It's the
flagship feature of the library: the same words a screen reader announces are the words a crawler indexes and a model
can quote.

```tsx
<Sparkline data={[3, 5, 4, 8, 6, 9]} title="Weekly revenue" />
// accessible name:
// "Weekly revenue. Trending up 200%. Range 3 to 9. Last value 9."
```

The name is `title` + the generated summary, joined into one sentence. The summary is deterministic — the same data
always produces the same words — so it's testable, and every one of these strings in the docs is the real output, not an
approximation.

### Degenerate data stays honest

The single most common accessibility bug in charting is a broken summary for empty, single, or flat data. microcharts
produces an honest short form instead — every string below is the literal output of `describeSeries`:

| Data                 | Accessible summary                               |
| -------------------- | ------------------------------------------------ |
| `[]` / all-null      | `No data.`                                       |
| `[7]`                | `Single value 7.`                                |
| `[5, 5, 5, 5]`       | `Flat at 5.`                                     |
| `[9, 7, 8, 4, 5, 2]` | `Trending down 78%. Range 2 to 9. Last value 2.` |

## Every chart is an `img`

The accessible name is composed deterministically — no module-level id counters that could desync between server and
client and cause a hydration mismatch. Two levels:

- **Default** — a chart renders `role="img"` with a computed `aria-label` (and an id-less `<title>`). Nothing to wire;
  it just has a name.
- **Explicit `id`** — pass an `id` and the chart upgrades to a `<title>`/`<desc>` pair referenced by `aria-labelledby`,
  the richest form, for when you want the title and description addressable.

Override the whole sentence with a string when you have better words than the generator:

```tsx
<Sparkline data={data} summary="Revenue per week, Q3 — up 40% and accelerating." />
```

## Decorative opt-out

When the surrounding text already describes the data — the common case for a word-sized chart _inside_ a sentence — mark
the chart decorative with `summary={false}` so screen readers skip the redundancy rather than reading it twice.

```tsx
<p>
  Revenue climbed steadily <Sparkline data={data} summary={false} /> through the quarter.
</p>
```

A decorative chart renders `aria-hidden="true"` instead of `role="img"` — it leaves the accessibility tree entirely, and
no `<title>` or `<desc>` is emitted. On an `/interactive` entry the same call resolves the name: with nothing left to
announce, the wrapper drops `role="img"` and `tabIndex={0}` and takes `aria-hidden="true"` instead — so there's nothing
to tab to or activate, and the static and interactive entries tell one story.

## One interaction contract

The `/interactive` entries share a single contract across the catalog: learn it on a Sparkline and it already holds on
an ActivityGrid, a Dumbbell, or a Waterfall. What differs between charts is only what a _unit_ is — a point, a bar, a
day cell, a segment, a pair — and each chart's page names its own.

**Roving focus, one tab stop.** A chart is one focusable element (`role="img"`, `tabIndex={0}`), never a tab trap of one
stop per data point. Arrow keys rove between units: **←/↑** move to the previous unit, **→/↓** to the next — both axes,
so a grid navigates the same way a line does. **Home** and **End** jump to the first and last unit.

**Active vs selected.** Hovering or roving makes a unit _active_ — a transient read that clears when the pointer leaves
or focus blurs. **Enter** or **Space** (or a click, or a tap) _selects_ it, which **pins** the readout so it survives
blur and pointer-leave — a keyboard user can move focus elsewhere and the pinned value stays on screen. Selecting the
same unit again clears it, and **Escape** clears the selection from anywhere. The two states are visually distinct, so
"where I am" and "what I kept" never read as the same thing.

**Touch.** A tap pins a unit exactly like a click; dragging across the chart scrubs the active unit continuously. A drag
is treated as a scrub, never mistaken for a tap, so scrolling past a chart can't pin a value by accident.

**Announcements.** Every change of active or selected unit is written into a polite live region, so a keyboard or
screen-reader user reads the same detail a mouse user gets from the hover chip. The picker fires only when the _unit_
changes, not on every pointer move, so sweeping across a chart announces once per unit crossed rather than once per
pixel.

The callbacks mirror the states exactly. `onActive` fires as the active unit changes; `onSelect` fires on selection.
Both receive a `MicroDatum` — `{ index, value, label?, formatted? }`, where `index` identifies the unit, `value` is that
unit's primary encoded number (`null` when the unit encodes nothing), and `formatted` is the exact string the chart's
own readout chip would show — or `null` when the chart clears. Selection is controllable with `selectedIndex`, or seeded
once with `defaultSelectedIndex`. To move the value out of the chart entirely — a KPI number, a sentence, a cell that
updates as you hover — set `readout={false}` to keep the crosshair but drop the in-chart chip, and render
`datum.formatted` from `onActive` yourself.

Charts with only **one** unit — Delta, Progress, StatusDot, Bullet, and the rest of the scalar set — have nothing to
rove between, so they take `onSelect` alone (click, tap, Enter, or Space) and carry no selection state.

**Values that change on their own.** Fifteen single-value charts built for updating KPI cards take `live` (default
`true`): when the value changes, the new reading is re-announced through the polite region without anyone touching the
chart. Set `live={false}` to silence it. A few throttle that announcement so a streaming value never floods the buffer:

| Chart with `live`                                                                                                                         | Announcement throttle         |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| Progress                                                                                                                                  | whole-percent steps only      |
| MoonPhase, FillWord                                                                                                                       | at most once per second       |
| Delta, ProgressRing, StatusDot, TrendArrow, Thermometer, Hourglass, FatDigits, TallyMarks, DicePips, Honeycomb, PictogramRow, BalanceBeam | none — every change announces |

Two charts throttle without taking `live` at all: **EtaBar** and **TapeGauge** re-announce their reading as it changes,
but never more often than `announceEvery` milliseconds — 10 s and 5 s by default, and tunable per instance.

Three charts sit outside the contract, deliberately: **MinimapStrip** is a viewport-window slider (`role="slider"`, with
its own `onWindowChange([lo, hi])` range payload), **TokenConfidence** flows as inline HTML text and moves real focus
onto each _flagged_ token span — confident tokens carry no mark and are skipped — and **WindBarb** ships static-only.

Static entries carry no listeners at all; every bit of this is opt-in.

## Color is never the only channel

Direction and state are always doubled, so a chart survives with no color at all:

- **Delta** pairs a ▲/▼ glyph with its color — the glyph carries the sign.
- **SparkBar** encodes sign by _position_ above or below the baseline, not hue.
- **ActivityGrid** always ships a numeric summary alongside its intensity ramp.

The default stroke tokens are chosen against a **4.5:1 contrast** target on the page backgrounds they're designed for,
and the dark values are hand-tuned per token rather than inverted, so nothing washes out in dark mode. Contrast against
_your_ surface is still yours to check — charts paint no background, so a mark inherits whatever you place it on.

## Internationalization and RTL

Nothing about a chart is hard-coded to English or to left-to-right layout.

- **Localized numbers.** Every rendered number and every number in the summary goes through `Intl.NumberFormat`. Pass
  `locale` and `format` and the digits, grouping, and units follow the reader's locale:

  ```tsx
  <Sparkline data={data} locale="de-DE" format={{ style: "currency", currency: "EUR" }} />
  ```

  See [Formatting & scale](/docs/formatting) for the full `format` / `locale` story, including the exported
  `makeFormatter` helper for matching your prose numbers to a chart's.

- **Localized words.** The summary templates are a swappable contract (`SummaryStrings`; the English set ships as the
  exported `EN` dictionary). Spread `EN`, override the keys you're translating, and pass the result as `strings` —
  static and `/interactive` entries both accept it, so a server-rendered chart localizes without shipping any client
  JavaScript. Or override the whole sentence with a `summary` string. Either way the accessible name, the live-region
  announcements, and the readout chip on an interactive entry all speak your language, because all three read from the
  same dictionary.

  Six charts don't take `strings` on their static entry: Sparkline, ActivityGrid, and MusicStaff accept it on the
  `/interactive` entry only, while SparkBar, Delta, and Bullet compose their sentence inline — `summary` is the
  translation seam there.

  `summary` and `strings` stay the entire text surface, and that is now enforced rather than asserted. A guard renders
  22 interactive entries — the readouts are where the drift lives, and they only exist in a browser — each with a
  `strings` object of sentinel tokens, and fails on any English left in the output. It was written because the claim had
  quietly stopped being true: readouts had accumulated words like "median", "vs" and "queued" in component code, which
  is exactly the drift a guard catches and a promise does not.

  The generator itself is exported too: `import { describeSeries } from "@microcharts/react"` returns the exact sentence
  for a series, handy for tests, tooltips, or feeding a model.

- **RTL layouts.** A chart is direction-neutral SVG with a localized text name, so it drops into an RTL document without
  breaking — it's an `img`, not a run of text. Time-ordered charts keep their conventional oldest-to-newest reading
  order; you localize the summary and the number formatting, which is what a screen-reader user actually hears.

## System preferences, handled

- **`prefers-reduced-motion`** — entrance and update animations resolve instantly to their final state. Static charts
  have no `animate` prop and run no JavaScript animation; the one thing that moves inside a static host is
  `<Marker celebrate>`, whose particle burst is a pure-CSS one-shot that reduced motion also stills.
- **`forced-colors`** — data ink maps to system `CanvasText`, accents to `Highlight`, muted marks to `GrayText`, so a
  Windows High Contrast user sees a correct chart, not a blank box.
- **`prefers-contrast`** — strokes thicken and bands deepen automatically.

## Verify it yourself

Because the summary is deterministic and the markup is plain SVG, accessibility is testable in CI, not a manual audit:

```tsx
import { axe } from "vitest-axe";

const { container } = render(<Sparkline data={data} title="Revenue" />);
expect(await axe(container)).toHaveNoViolations();
// and assert the exact announced sentence:
expect(container.querySelector("[role=img]")).toHaveAccessibleName(
  "Revenue. Trending up 200%. Range 3 to 9. Last value 9.",
);
```
