@x12i/charts
v1.1.0
Published
Charts for the Unified UI Kit: one JSON ChartSpec, a token-themed SVG renderer, an optional ECharts canvas renderer for large data, and accessible text alternatives.
Maintainers
Readme
@x12i/charts
Accessible charts for the Unified UI Kit. You describe a chart as one
JSON-serializable ChartSpec; the package draws it as SVG with the kit's design
tokens, adds a legend, tooltip, keyboard navigation, loading/empty/error states,
a generated text description and a data table, and switches to an ECharts canvas
for very large data.
- 16 chart types: bar, lollipop, line, area, step, sparkline, combo (bars and lines, optionally on two axes), scatter, bubble, histogram, waterfall, donut, pie, heatmap, gauge and progress ring.
- Accessible by construction: every chart has a name, a description, keyboard navigation, a screen-reader announcement per point and a "View as table" toggle. Color is never the only way to tell series apart.
- Tokens only: no colors, sizes or fonts of its own. Light, dark and
high-contrast themes work through
data-theme. - Right to left and localized: logical CSS, mirrored axes,
Intlfor numbers, dates and lists, and a message catalog for every word it shows. - Server-safe: the root entry has no React and no DOM.
Install
pnpm add @x12i/charts @x12i/ui @x12i/themes react react-dom
# Only if you want the canvas renderer (see "Performance"):
pnpm add echarts@x12i/ui, react and react-dom are peer dependencies; echarts is an
optional peer dependency. The package depends on @x12i/tokens and a few
small D3 modules (d3-array, d3-scale, d3-shape, d3-time, d3-format,
d3-time-format; all ISC). It is ESM only.
Import both stylesheets once, and set the theme on a parent element:
import "@x12i/ui/styles.css";
import "@x12i/charts/styles.css";The stylesheet is made of zero-specificity rules (:where(...)) on
data-kit="chart-*" hooks, so your own CSS always wins without !important.
Two entry points
| Import | Contents | Needs |
| --- | --- | --- |
| @x12i/charts | The headless core: ChartSpec types and defineChartSpec, validation, data transforms, statistics, scales and layout math, formatting, message catalog, text descriptions, data tables and CSV, the ECharts option builder. | Nothing. No React, no DOM, no browser globals. Safe in server code, workers and scripts. |
| @x12i/charts/react | Chart, ChartFrame, ChartCard, ChartGrid, ChartDataTable, ChartLegend, ChartState, ChartConfigProvider, SvgChart, EChartsChart. | React 19, @x12i/ui. |
dist/react.js starts with "use client", so the components work in a Next.js
App Router tree without wrapping. The root entry has no directive and runs in
server components, route handlers and workers.
Server rendering
Chart renders on the server. Without a width it uses a fixed default width
(640) on the server and on the first client render, so hydration matches;
then it measures its container and re-draws to fit. Pass width and height
for charts that must not move. The default locale is the fixed "en", never the
runtime's, so the server and browser print identical text. Pass locale (or set
it once with ChartConfigProvider) to localize.
// A server component, route handler or script: no React involved.
import { chartToCsv, describeChart, validateChartSpec } from "@x12i/charts";
const issues = validateChartSpec(spec);
const { name, text } = describeChart(spec); // the screen-reader description
const csv = chartToCsv(spec, { locale: "de" });Quick start
import { ChartFrame } from "@x12i/charts/react";
import type { ChartSpec } from "@x12i/charts";
// Define specs outside the component, or memoize them. A new object on every
// render makes the chart rebuild its model on every render.
const tickets: ChartSpec = {
title: "Platform has the most open tickets",
source: "Support desk, 1 Nov 2025",
data: {
values: [
{ team: "Platform", tickets: 42 },
{ team: "Growth", tickets: 31 },
{ team: "Payments", tickets: 18 },
{ team: "Search", tickets: 12 },
],
},
mark: "bar",
x: { field: "team", type: "category", title: "Team" },
y: { field: "tickets", type: "number", title: "Open tickets" },
highlight: { where: "team == 'Platform'" },
labels: { values: true },
a11y: { description: "Platform has 42 open tickets, more than any other team." },
};
export function TicketsChart() {
return <ChartFrame spec={tickets} />;
}ChartFrame is a <figure> with a heading (the title states the finding), a
subtitle, a toolbar (View as table, Download CSV), the chart, the source
line and the data table. Use Chart alone when you supply your own frame, and
ChartCard for the same frame on a bordered surface.
Line
const revenue: ChartSpec = {
title: "Revenue outgrew cost every month",
subtitle: "Monthly, USD thousands",
source: "Finance ledger",
data: {
values: [
{ month: "2025-01-01", revenue: 120, cost: 90 },
{ month: "2025-02-01", revenue: 135, cost: 96 },
{ month: "2025-03-01", revenue: 128, cost: 99 },
{ month: "2025-04-01", revenue: 160, cost: 104 },
],
},
mark: "line",
x: { field: "month", type: "time", title: "Month" },
y: { field: "revenue", type: "number", title: "USD thousands" },
series: [
{ id: "revenue", label: "Revenue", field: "revenue" },
{ id: "cost", label: "Cost", field: "cost" },
],
annotations: [{ type: "reference-line", y: 130, label: "Target" }],
a11y: { description: "Revenue rose from 120 to 160 while cost rose from 90 to 104." },
};
<ChartFrame spec={revenue} />;Series that differ in kind are listed in series. Without series, one series
is drawn from y, or one per value of color: { field }.
Bar variants
// Grouped (default), stacked, normalized to 100%, or horizontal:
{ mark: "bar", color: { field: "region", type: "category" } }
{ mark: "bar", color: { field: "region", type: "category" }, stack: "stacked" }
{ mark: "bar", color: { field: "region", type: "category" }, stack: "normalized" }
{ mark: "bar", orientation: "horizontal" }Donut
const share: ChartSpec = {
title: "Platform and Growth hold three quarters of tickets",
data: { ref: "tickets" }, // resolved from `datasets`
mark: "donut",
x: { field: "team", type: "category", title: "Team" },
y: { field: "tickets", type: "number", title: "Open tickets" },
donut: { centerCaption: "tickets" }, // the center shows the total
a11y: { description: "Platform 41 percent, Growth 30 percent, the rest 29 percent." },
};
<ChartFrame spec={share} datasets={{ tickets: rows }} />;Slices beyond donut.maxSlices (default 5) merge into "Other". Slices show their
share; the legend toggles them.
Sparkline
import { Chart } from "@x12i/charts/react";
const trend: ChartSpec = {
title: "Revenue trend",
data: { values: monthly },
mark: "sparkline",
x: { field: "month", type: "time" },
y: { field: "revenue", type: "number" },
a11y: { description: "Revenue trends up." },
};
// 48 high by default. A non-interactive sparkline is a labelled image.
<Chart spec={trend} width={120} height={32} interactive={false} />;Other types
| mark | Key fields | Notes |
| --- | --- | --- |
| area | x, y, optional color + stack | Stacked or normalized with stack. |
| step | x, y | Steps instead of slopes. |
| lollipop | x (category), y | A stem and a dot per category. |
| combo | series with mark: "bar" \| "line" \| "area" \| "step" \| "scatter" | Second axis needs dualAxis: true and axis: "y2"; a visible warning is added. |
| scatter | x (number), y | Optional color for groups. |
| bubble | x, y, size | Bubble area encodes size. |
| histogram | x (the measure) and bins: { count } or bins: { thresholds } | Bins the raw values. |
| waterfall | x (steps), y (change), waterfall: { totals } | Increase, decrease and total bars with connectors. |
| heatmap | x, y (categories), value | Sequential ramp, or diverging when values cross zero. |
| gauge | gauge: { value, min, max, target, bands, format } | data: { values: [] } is fine for a literal value. |
| progress-ring | gauge: { value, min, max, format: "percent" } | |
Everything else in chartTypes (for example treemap, sankey, stat)
is reserved in the spec but not drawn yet; see "Limitations".
ChartSpec in brief
interface ChartSpec {
title: string; // a sentence that states the finding (required)
subtitle?: string; // scope and units
source?: string; // where the data came from
data: { ref: string } | { values: Row[] };
mark: ChartType;
x?: Encoding; y?: Encoding; color?: Encoding; size?: Encoding; value?: Encoding;
series?: SeriesSpec[]; // explicit series: id, label, field | points, mark, axis, tone, marker, dash, stack, hidden, format
stack?: "none" | "stacked" | "normalized" | "diverging";
orientation?: "vertical" | "horizontal";
scales?: { x?, y?, y2? }; // linear | log | sqrt | time | band | ordinal, domain, nice, zero, reverse, padding
axes?: { x?, y?, y2? }; // show, title, grid, ticks, tickValues, format, labelAngle, maxLabelLength
thresholds?: Threshold[]; // labelled limits; points that breach get an extra mark and a note
annotations?: Annotation[]; // reference-line, reference-band, text, forecast, point (all labelled)
transforms?: Transform[]; // aggregate, bin, stack, percent-of-total, sort, top-n, rolling-average, cumulative-sum, period-change, downsample
highlight?: { where: string }; // one accent: `team == 'Platform'`
legend?: { show?: boolean | "auto"; position?: "top" | "bottom" };
labels?: { direct?: boolean | "auto"; values?: boolean };
interactions?: { tooltip?; legendIsolate? };
dualAxis?: boolean;
renderer?: "svg" | "canvas" | "auto";
direction?: "ltr" | "rtl" | "auto";
timeZone?: string; // IANA zone for formatting time values (default "UTC")
a11y: { description: string; table?: boolean }; // description is required
}Formats are named (compact, percent, integer, decimal, currency:USD,
date, datetime, time, month, year), d3-format specifiers (,.2f) or
d3-time-format patterns (%b %Y).
validateChartSpec(spec, { datasets }) returns { severity, code, path, message }
issues for anything you can get wrong (missing title, unknown field, a bar chart
with a truncated baseline, a second axis without dualAxis, too many series,
too many rows for SVG, ...). isValidChartSpec is the boolean version.
Design rules the validator and defaults enforce: bars start at zero, one accent on grey, at most 8 categorical hues, dual axes only behind an explicit flag, targets and limits always labelled.
Component API
Chart / SvgChart / EChartsChart
| Prop | Type | Description |
| --- | --- | --- |
| spec | ChartSpec | The chart. Memoize it. |
| datasets | Record<string, Row[]> | Rows for data: { ref }. Falls back to ChartConfigProvider. |
| width, height | number | Layout units. Default: fill the container; height by type (320, sparkline 48, gauge 220). |
| locale | string | BCP 47 tag for numbers, dates, lists and plurals. Default "en". |
| messages | ChartMessagesInput | Overrides for every word the chart shows or announces. |
| dir | "ltr" \| "rtl" | Text direction. Default: the page's dir, then the locale. |
| renderer | "svg" \| "canvas" \| "auto" | Overrides spec.renderer. |
| target | "screen" \| "print" \| "report" | print and report always use SVG. |
| loadEcharts | () => Promise<{ init }> | Loads echarts for canvas. Default import("echarts"). |
| loading, error, onRetry | boolean, unknown, () => void | Loading and error states at the chart's height. |
| hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange | string[] | Controlled or uncontrolled legend toggles. |
| interactive | boolean | false draws a static, non-focusable labelled image. Default true. |
| tooltip | boolean | Default spec.interactions.tooltip !== false. |
| onPointActivate | (info: ChartPointInfo, event) => void | Click, Enter or Space on a point. |
| onActivePointChange | (info \| null) => void | The hovered or focused point changed. |
| className, style, id | | On the root element. |
SvgChart always draws SVG. EChartsChart always uses canvas (with an SVG
fallback, see below). ChartPointInfo has seriesId, seriesLabel,
seriesIndex, pointIndex, x, xLabel, y, size and row (the source row).
ChartFrame / ChartCard
Everything Chart takes, plus headingLevel (2-6, default 3), toolbar
(default true), actions (extra buttons), view / defaultView /
onViewChange ("chart" | "table"), tableMaxRows (default 200; the CSV always
has every row), csvFileName, onDownload(csv, fileName) (replaces the browser
download) and variant ("plain" | "card").
ChartGrid, ChartDataTable, ChartLegend, ChartState
ChartGrid lays charts out in a responsive grid (columns fixes the count,
label makes it a named group). ChartDataTable is the data table on its own.
ChartLegend and ColorScaleLegend and ChartState are the building blocks,
exported for custom layouts.
ChartConfigProvider
Sets locale, messages, direction, datasets, loadEcharts and target
for every chart inside. Providers nest and merge (the inner one wins; markNames
merge key by key); props on a chart win over the provider.
<ChartConfigProvider locale="de" messages={deMessages} datasets={datasets}>
<ChartFrame spec={spec} />
</ChartConfigProvider>Theming
Series colors are --kit-color-chart-* tokens (accent, muted, reference,
good, warning, critical, categorical-1 to categorical-8); heatmaps use
the --kit-color-sequential-* and --kit-color-diverging-* ramps; axes and
gridlines use --kit-color-chart-axis and --kit-color-chart-grid. Everything
follows the theme set by ThemeProvider / data-theme (light, dark,
high-contrast) and the density tokens. To change a color, override the token
where you need it:
.my-dashboard {
--kit-color-chart-categorical-1: var(--kit-color-accent-default);
}Specs choose a tone by name (series: [{ id: "a", tone: "good" }]), never a
color. The canvas renderer reads the same variables at runtime and redraws when
data-theme changes on the page or an ancestor.
Every part of a chart has a hook: data-kit="chart", chart-svg, chart-line,
chart-bar, chart-point, chart-arc, chart-cell, chart-axis,
chart-tick-label, chart-legend, chart-tooltip, chart-state,
chart-frame, chart-table and so on, with states as data-* attributes
(data-hidden, data-active, data-highlighted, data-muted, data-breach,
data-state, data-renderer). The stylesheet is tested to contain no hex or
rgb() colors, no px/rem lengths, only logical properties and only existing
--kit-* variables.
Accessibility
- Name and description. The plot is
role="application"with anaria-labelof "{title} ({chart type})",aria-roledescription="chart"and anaria-describedbypointing to a generated description: chart type, series and point counts, ranges, trends, the highest and lowest points, thresholds and breaches, plus your owna11y.description. - Keyboard. Tab to the chart, then the arrow keys move between points: left
and right along the categories, up and down between series at the same
category; on a heatmap along both axes; on a scatter plot to the nearest point
in that direction; through the slices of a pie. Home / End jump to the first
and last category, PageUp / PageDown move ten categories, Enter or Space
activates (
onPointActivate), Escape dismisses the tooltip. In a right-to-left chart, left and right swap. A visible focus ring is drawn on the focused mark. - Screen readers. Each focused point is announced politely through a live
region ("Mar 1, 2025, Cost: 99. 6 of 12."). The tooltip is for sighted users
and is
aria-hidden, so nothing is announced twice. The decorative SVG groups arearia-hidden. - Data table.
ChartFramealways renders the data as a real<table>with caption, column headers and row headers: visible in table view, visually hidden (but in the accessibility tree) in chart view. Disable witha11y: { table: false }. - Color is never the only channel. Series differ by hue and marker shape and dash style (lines), or hatch pattern (bars, areas, slices; eight patterns cycle so hue + pattern pairs stay unique for 64 series). Threshold breaches get an extra mark and a note in the tooltip. Waterfall decreases are hatched, and the tooltip names every change with a sign. Status annotations carry text.
- Legend. Entries are
<button aria-pressed>; hidden series are removed from the plot, the description and the announcements. - Reduced motion. Animation is skipped when
prefers-reduced-motion: reduce. - Forced colors. In Windows high-contrast mode, marks use system colors and keep their outlines and patterns.
- Touch. A tap selects a point and keeps its tooltip after the finger lifts (a hovering mouse hides it when the pointer leaves).
- States. Loading (
role="status",aria-busy), empty, missing dataset and unsupported type userole="status"; errors userole="alert"with an optional retry. All reserve the chart's height so the page does not jump.
Tested with axe (vitest-axe) on every chart type, in chart and table view, in
every state, in right-to-left, and on the canvas renderer, with zero violations.
Axe cannot check color contrast in jsdom; contrast is guaranteed by the token
pairs in @x12i/themes.
Internationalization
- Every word the charts print or announce is in
defaultMessages(86 keys: states, toolbar, table columns, tooltips, point announcements, the generated description, trends). Passmessageswith any subset. Templates use an ICU MessageFormat subset:{name},{n, number},{n, plural, =0 {none} one {# row} other {# rows}}(#is the number) and{kind, select, up {rose} other {fell}}. Values you pass as text are shown as-is; numbers go throughIntlfor{n, number}and for plural rules.markNamesrenames chart types ("line chart"). - Numbers, dates, times, percentages, compact numbers and lists go through
Intlwith yourlocale; the default is the fixed"en". - Right-to-left (Arabic, Hebrew, Persian, Urdu, ...): the chart sets
dir, time and category axes mirror, the value axis moves to the other side, label anchors flip for right-to-left text and keyboard left/right swap. Numeric labels stay left-to-right. Direction comes from thedirprop, then the page'sdirattribute (observed for changes), then the locale.
<ChartConfigProvider
locale="ar"
messages={{ viewAsTable: "عرض كجدول", downloadCsv: "تنزيل CSV", legend: "المفتاح" }}
>
<ChartFrame spec={spec} />
</ChartConfigProvider>Performance
| Data size | Renderer | Notes |
| --- | --- | --- |
| Up to ~5,000 points | SVG | Fully accessible, keyboard navigable, vector. Always used for target="print" and "report". |
| Above 5,000 points | Canvas (renderer: "auto") | SVG_POINT_LIMIT = 5000. Needs echarts. |
Before reaching for canvas, thin the data: transforms: [{ type: "downsample",
method: "lttb", field: "value", threshold: 1000 }] keeps the visual shape of a
line with a fraction of the points, and aggregate / bin reduce rows before
drawing. The 5,000-point threshold is a comfort limit, not a measured
guarantee: DOM-based SVG gets heavier with every mark, so test on your slowest
target device. Layout is memoized on the spec, size, locale, direction and
hidden series, so memoize your spec.
The canvas renderer
EChartsChart, renderer="canvas" and renderer="auto" above the limit draw
with Apache ECharts, loaded lazily by import("echarts") the first time a chart
needs it (apps that only draw SVG never download it). Colors are read from your
--kit-* variables, and the chart redraws on theme changes. The legend, title,
frame, data table and text description are the same as for SVG.
If echarts cannot be loaded, or cannot draw the particular chart (for example
a waterfall that crosses zero), the chart falls back to SVG and shows a note.
echarts is an optional peer, and bundlers resolve import("echarts") at build
time, so if you do not install it your bundler may warn or fail on that import.
Either install echarts, or pass your own loader, which also lets you register
only the echarts modules you use:
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
<ChartConfigProvider loadEcharts={async () => echarts}>{children}</ChartConfigProvider>;CSV and tables
chartToTable(spec) and chartToCsv(spec, { locale, delimiter, bom }) produce
the same table the component shows; the CSV is RFC 4180 (CRLF line ends, quoted
fields). The download button adds a UTF-8 byte-order mark so spreadsheets open it
correctly, and onDownload lets you send the CSV to your own handler.
Headless API
Everything in @x12i/charts, by area:
- Spec:
defineChartSpec,chartTypes, theChartSpectypes. - Validation:
validateChartSpec,isValidChartSpec. - Model and data:
buildChartModel,supportedMarks,isSupportedMark,applyTransforms,resolveRows,countPoints,effectiveStack,readField,histogramBins,stackValues,lttb,minMax,compileWhere/evaluateWhere(thehighlight.whereexpression language). - Statistics:
mean,median,quantile,sum,variance,standardDeviation,extent,correlation,linearRegression,regressionBand,forecastLinear,movingAverage,cumulativeSum,percentChange,periodChange,trendDirection. - Scales and layout:
createContinuousScale,createBandScale,createPointScale,niceTicks,fitTickLabels,layoutChart(spec model to plain geometry; the SVG component only draws it),patternIndex. - Interaction as pure functions:
hitTest,navigate,tooltipFor,describeTarget,pointInfo,isNavKey. - Formatting and messages:
createFormatters,createNumberFormatter,createDateFormatter,formatList,formatShare,isRtlLocale,DEFAULT_LOCALE,defaultMessages,resolveMessages,formatMessage. - Text alternatives and exports:
describeChart,describeModel,chartToTable,modelToTable,chartToCsv,tableToCsv. - Renderer:
resolveRenderer,SVG_POINT_LIMIT. - Canvas mapping:
toEChartsOption,themeColorsFromTokens(no echarts import).
Because layout is a pure function, you can render a chart to your own target
(a PDF, a canvas, an email) from layoutChart(buildChartModel(spec), { width,
height, rtl }).
Limitations
Honest list of what is not there yet:
- Chart types not drawn.
chartTypesreserves 50 types so saved specs stay valid, but the renderer draws the 16 insupportedMarks. Others (treemap,sankey,box-plot,stat,funnel,calendar-heatmap, maps, ...) show an "unavailable" state, andvalidateChartSpecwarns. - Interactions.
interactions.brush,zoomanddrilldownare in the spec for forward compatibility and are ignored (the validator warns). There is no shared filter store. Legend toggling, isolate (legendIsolate), tooltips, keyboard navigation andonPointActivatework. - Canvas. The canvas renderer has no keyboard navigation and no right-to-left mirroring (it is an image with a description, the table and the legend); use SVG when you need them. A chart that falls back to SVG (echarts missing, or a type canvas cannot draw) stays on SVG until it is re-mounted.
- Forced colors. In Windows high-contrast mode, heatmap cells lose their color ramp (system colors have no ramp); the values are in the tooltip, the labels and the table.
- Time axes are laid out in UTC;
timeZonechanges how values are formatted, not where they sit on the axis. - Waterfalls that cross zero draw on SVG only.
- Gauges are single-value.
format: "percent"is the share of the min to max range: 64 of 100 and 0.64 of 1 both read "64%". A literal gauge still needsdata: { values: [] }. - Hatch patterns are applied automatically (the second series onward in a
multi-series bar, area or slice chart); they are an option of
layoutChart(patterns: true), not a spec field. - Message catalog is English only. Other languages come from your
messages. - Contrast depends on your theme; axe cannot measure it in jsdom.
Development
pnpm --filter @x12i/charts run build # tsup (two entries) + copy styles.css
pnpm --filter @x12i/charts run typecheck
pnpm --filter @x12i/charts run lint # also bans hard-coded colors and px/rem in src
pnpm --filter @x12i/charts run test # vitest: headless units, components, axe, styles, SSRThe tests cover every headless function, every chart type (marks, axes, legend,
tooltip, keyboard, states, messages, right-to-left), axe on every chart type,
the stylesheet's token rules, server rendering, and the canvas renderer with a
mocked echarts.
