# Accessibility (/docs/accessibility)

Every chart carries its accessibility with it. The accessible name is generated from your data, so there is no `alt`
text to write and no caption to update when the numbers change.

## Generated summaries

By default a chart is an `img` whose accessible name is a sentence built from the series. A screen reader announces that
sentence, a crawler indexes it, and a model can quote it.

```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` plus the generated summary, joined into one sentence. The summary is deterministic: the same data
always produces the same words, so you can assert on it in a test. Every summary string in these docs is real generator
output.

### Empty, single, and flat data

Empty, single-point, and flat series are where a generated summary usually breaks. Each one gets its own short form.
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 name is composed deterministically, with 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>`). You wire nothing.
- **Explicit `id`** — pass an `id` and the chart upgrades to a `<title>`/`<desc>` pair referenced by `aria-labelledby`,
  so both the title and the description are 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, mark the chart decorative with `summary={false}` and screen
readers skip it. That is the common case for a chart set inside a sentence.

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

A decorative chart renders `aria-hidden="true"` in place of `role="img"`. It leaves the accessibility tree entirely, and
no `<title>` or `<desc>` is emitted. An `/interactive` entry resolves the name the same way: with nothing left to
announce, the wrapper drops `role="img"` and `tabIndex={0}` and takes `aria-hidden="true"`, so it stops being a tab stop
and can no longer be activated. Static and interactive entries match.

`summary={false}` drops the generated _sentence_; the chart leaves the tree only when that leaves it with no name at
all. With a `title` still set, the chart keeps `role="img"` and is announced as its title. Drop the `title` too when you
want silence.

## One interaction contract

The `/interactive` entries share one contract across the catalog: learn it on a Sparkline and it holds on an
ActivityGrid, a Dumbbell, or a Waterfall. Only the _unit_ differs between charts — 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}`), not a tab trap of one
stop per data point. Arrow keys rove between units: **←/↑** move to the previous unit, **→/↓** to the next. Both axes
work, 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**, **Space**, a click, or a tap _selects_ it and **pins** the readout, so it survives blur and
pointer-leave. A keyboard user can move focus elsewhere and the pinned value stays on screen. The two states are drawn
differently, so "where I am" and "what I kept" never look the same.

**Getting out of a selection.** Three ways, and none of them is a shortcut you have to be told about. Select the same
unit again. Press **Escape** — from the chart, or from anywhere else on the page while the pin is up. Or click, tap, or
press anywhere outside the chart: a pointer press that is not the chart's own drops the pin and fires `onSelect(null)`.
Blur is deliberately not one of them, so tabbing to a detail panel keeps the value you pinned. On a controlled chart
(`selectedIndex`), all three report `onSelect(null)` and leave the pin where your state put it.

**Touch.** A tap pins a unit the way a click does; dragging across the chart scrubs the active unit continuously. A drag
always resolves as a scrub, 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 detail the hover chip shows. The picker fires when the _unit_ changes, not on every pointer
move, so sweeping across a chart announces once per unit crossed.

The callbacks mirror the two states. `onActive` fires as the active unit changes; `onSelect` fires on selection. Both
receive a `MicroDatum` (`{ index, value, label?, formatted? }`), or `null` when the chart clears. `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. Control the selection with `selectedIndex`, or seed it once with
`defaultSelectedIndex`. To render the value outside the chart — a KPI number, a sentence, a cell that updates as you
hover — set `readout={false}` to keep the crosshair without the in-chart chip, and render `datum.formatted` from
`onActive` yourself.

Charts with a single unit (Delta, Progress, StatusDot, Bullet, and the rest of the scalar set) have no second unit to
rove to, so they take `onSelect` alone (click, tap, Enter, or Space) and carry no selection state. Hover and focus still
reveal the value: a scalar whose mark does not print its own number floats it in the same chip the pickers use, so a
ProgressRing arc, an Hourglass, a BreathingDot, or a TapeGauge too small for its numeral still gives you a reading. Six
glyphs skip the chip because they already are the number, or a count you read by counting: Delta, FatDigits, TrendArrow,
StatusDot, DicePips, TallyMarks.

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

Two charts sit outside the contract on purpose. **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.

Static entries carry no listeners, so all of this is opt-in.

## Color is never the only channel

Direction and state are always encoded twice, so a chart still reads with no color at all:

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

The valence tokens are CVD-safe by construction: positive is a bluish-green and negative a vermillion, a split that
stays legible to a red-green colorblind reader. The default stroke tokens clear a **4.5:1 contrast** target on the page
backgrounds they are designed for, and the dark values are hand-tuned per token rather than inverted, so they hold up 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` rules, 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 use your translation, 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` are the entire text surface, and a guard enforces that. It renders 22 interactive entries,
  each with a `strings` object of sentinel tokens, and fails on any English left in the output. It targets readouts
  because they only render in a browser, which is where the claim had stopped being true: readouts had accumulated words
  like "median", "vs" and "queued" in component code.

  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 as an
  `img` rather than a run of text. Time-ordered charts keep their conventional oldest-to-newest reading order. What a
  screen-reader user hears is the summary and the number formatting, and both localize.

## Reduced motion, forced colors, and contrast

- **`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 the chart rather than a blank box.
- **`prefers-contrast`** — strokes thicken and bands deepen automatically.

## Verify it yourself

The summary is deterministic and the markup is plain SVG, so you can assert accessibility in CI instead of running 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.",
);
```
