Skip to content
microcharts

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.

Announced automatically
Weekly revenue

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:

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 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:

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

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