Skip to content
microcharts

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.

Announced automatically
Weekly revenue

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:

DataAccessible summary
[] / all-nullNo 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:

<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 liveAnnouncement throttle
Progresswhole-percent steps only
MoonPhase, FillWordat most once per second
Delta, ProgressRing, StatusDot, TrendArrow, Thermometer, Hourglass, FatDigits, TallyMarks, DicePips, Honeycomb, PictogramRow, BalanceBeamnone — 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:

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

    See Formatting & scale 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:

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.",
);