Accessibility
How microcharts names, announces, and navigates a chart: generated summaries, the decorative opt-out, the keyboard and live-region contract, color and locale, and system preferences.
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.
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 computedaria-label(and an id-less<title>). You wire nothing. - Explicit
id— pass anidand the chart upgrades to a<title>/<desc>pair referenced byaria-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:
<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.
<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. Selecting the same unit again clears it, and Escape clears the selection from anywhere. The two states are drawn differently, so "where I am" and "what I kept" never look the same.
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.
Three 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. WindBarb ships static-only.
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. 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/localerules, 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 use your translation, 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.summaryandstringsare the entire text surface, and a guard enforces that. It renders 22 interactive entries, each with astringsobject 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
imgrather 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 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 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:
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
How to theme microcharts by setting a --mc-* custom property at any scope, applying a named preset, or deriving a whole palette from one accent with defineTheme().
Performance
Where the size and speed numbers come from: 106 chart types at ~2–7 kB interactive · ~1–4 kB static, a static server render of about 0.03 ms each, and the commands that regenerate every figure.