Accessibility
Accessible React charts by default — every microchart is role="img" with a generated natural-language summary, keyboard-ready interactive entries, CVD-safe tokens, and forced-colors / reduced-motion support. No extra a11y work.
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.
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 computedaria-label(and an id-less<title>). Nothing to wire; it just has a name. - Explicit
id— pass anidand the chart upgrades to a<title>/<desc>pair referenced byaria-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:
<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.
<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. Passlocaleandformatand the digits, grouping, and units follow the reader's locale:<Sparkline data={data} locale="de-DE" format={{ style: "currency", currency: "EUR" }} />See Formatting & scale for the full
format/localestory, including the exportedmakeFormatterhelper 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 exportedENdictionary). SpreadEN, override the keys you're translating, and pass the result asstrings— static and/interactiveentries both accept it, so a server-rendered chart localizes without shipping any client JavaScript. Or override the whole sentence with asummarystring. 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
stringson their static entry: Sparkline, ActivityGrid, and MusicStaff accept it on the/interactiveentry only, while SparkBar, Delta, and Bullet compose their sentence inline —summaryis the translation seam there.summaryandstringsstay 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 astringsobject 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 noanimateprop 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 systemCanvasText, accents toHighlight, muted marks toGrayText, 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:
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.",
);Theming
Theme microcharts with a couple dozen CSS custom properties, named presets, and a defineTheme() builder — override a token at any scope, derive a full palette from one brand colour, switch presets with a data attribute, keep semantic colors meaning what they say, and get hand-tuned dark mode and forced-colors support for free.
Performance
Zero dependencies, budget-gated sizes, and server rendering with no client JavaScript — 106 chart types at ~2–7 kB interactive · ~1–4 kB static, static SSR median about 0.03 ms each. Every number here is reproducible from one command.