Charts
@nizivo/charts: server-rendered SVG charts with one small client island, a data table as the accessible alternative, and a frame that keeps Nizivo's caption, table, legend and status around any third-party renderer.
The parts
| Part | Import | Kind | Ships client JS under React Server Components |
|---|---|---|---|
| ChartFrame | @nizivo/charts/chart-frame | server | Never |
| AreaChart | @nizivo/charts/area-chart | split | The ChartInteraction island, when status is 'ready' and interaction is 'nizivo' (default) |
| BarChart | @nizivo/charts/bar-chart | split | The ChartInteraction island in column mode, when status is 'ready' and interaction is 'nizivo' (default) |
| DonutChart | @nizivo/charts/donut-chart | split | The ChartInteraction island in item mode, when status is 'ready' and interaction is 'nizivo' (default) |
| FunnelChart | @nizivo/charts/funnel-chart | server | Never |
| Heatmap | @nizivo/charts/heatmap | server | Never |
| Sparkline | @nizivo/charts/sparkline | server | Never |
| Legend | @nizivo/charts/legend | server | Never |
| ChartTooltip | @nizivo/charts/chart-tooltip | server | Never |
The kinds are the ones the static or interactive guide uses
for @nizivo/react. AreaChart, BarChart and DonutChart are the parts whose modules import a client
module, the island; the others import none. island-graph.test.ts in packages/charts/test/unit
checks that AreaChart, BarChart and DonutChart reach exactly the island and that Sparkline,
ChartTooltip, FunnelChart and Heatmap reach no client module.
Install
pnpm add @nizivo/charts @nizivo/tokensimport '@nizivo/tokens/tokens.css';
import '@nizivo/charts/styles.css';@nizivo/charts depends on d3-shape (^3.2.0), d3-array (^3.2.4) and @nizivo/tokens, with
react and react-dom ^19.2.0 as peers. It does not need @nizivo/react or any of its CSS:
styles.css carries its own focus outline and visually hidden rule, scoped to the chart classes.
The charts-only lane (node fixtures/runner/charts.ts) serves only tokens.css and the charts
styles.css and runs axe and keyboard-focus checks on that page set;
node fixtures/runner/charts.ts --engines chromium,webkit,firefox --record commits its results per
engine to fixtures/charts-only/results/ (chromium.json, webkit.json, firefox.json), like the
other fixtures (without --engines it runs, and records, Chromium only).
Server first
Every renderer is a Server Component with no 'use client'. It computes the whole SVG on the server:
the value domain (rounded out to whole ticks before the ticks are taken, so no value rises above the
top label), the paths, the tick labels and the data table. Numbers and dates are formatted there,
once, with Intl and an explicit locale (default 'en-US'); nothing reads navigator.language.
Dates are ISO plain dates ('2026-10-19'), formatted in UTC.
The geometry needs no client code:
- Lines and areas are drawn in a
0 0 100 100viewBox stretched over the plot (preserveAspectRatio="none"), withvector-effect: non-scaling-stroke, so a 2 px line stays 2 px at any size. This was verified on 2026-10-19 in Chromium 156, Firefox 157 and WebKit 27.2 (Playwright builds), andstroke.spec.tsxinpackages/charts/test/browserkeeps checking it in the three engines. Real Safari and the Safari 17.5 floor are not covered. Path coordinates are rounded to 2 decimals (PATH_DIGITSinpackages/charts/src/_core/path.ts): 0.01 of the plot is 0.07 px on Figma 03's 720 px plot. - Tick labels are HTML on CSS grid tracks, so no label needs a position of its own.
- Bars are
<rect>elements in percentage units; BarChart caps them at 22 px with CSS.
One API shape
Every chart takes its data, formats and strings the same way (M40), from one module,
_core/api.ts in packages/charts/src:
- A datum's value is an
Accessor: a key of the datum whose values have the right type (x="date", which a Server Component can pass) or a(datum, index) => valuefunction. AreaChart and BarChart takexandy, DonutChartcategoryandvalue, AreaChart's comparisoncompare, and Sparkline an optionaly. - Every
*Formatprop is plainIntl.NumberFormatOptionsorIntl.DateTimeFormatOptions, as@nizivo/react's Delta and Meter take them; there are no presets.formatdefaults toIntl.NumberFormat's own options ({}) in every chart. - The cartesian charts (AreaChart, BarChart) share
CartesianChartOptions:xTicks: { every?, max? }for the x labels andyTicks(about how many y labels, default 4). - A chart's strings are
XLabels extends ChartFrameLabels, with English defaults inxLabels(areaChartLabels), andlabelstakesPartial<XLabels>. - A chart's props (
AreaChartPropsand the others) include the<figure>'s attributes, as@nizivo/react's parts do, and ChartFrame's own props it passes through (ChartFramePassthrough):title,summary,titleVisibility,status,pending,tableViewand the rest, the same in every chart.titleVisibility="hidden"hides the caption visually and keeps it as the figure's accessible name (ChartFrame).
The one island
The only client module in the package is the ChartInteraction island
(chart-interaction.client.tsx in src/_island). AreaChart, BarChart and DonutChart render it
over the plot when their status is 'ready' and interaction is 'nizivo'. It receives plain JSON: every label
and value already formatted, each series' name once and one short entry per point, so it formats
nothing and reads no locale. It adds:
- one Tab stop over the plot, with the arrow keys, Home, End and Escape;
- in point mode (AreaChart), a crosshair, a dot per series and the ChartTooltip card at the active point;
- in column mode (BarChart),
data-highlightedon the active bar column, whose CSS dims the others, and the same card beside it; - in item mode (DonutChart, M41), the same over marks that are not bands: the arrow keys move over
the slices in data order (clockwise; the ring is never mirrored) starting at the first,
data-highlightedgoes on the active slice, the card sits at the slice's centre with its share "of total", and a pointer picks the segment under it; - one polite live region, empty in the server HTML, that speaks a point when the keyboard reaches it
(never on hover), joined with the list separators
Intl.ListFormatgives the chart'slocale.
Against WCAG 1.4.13 (Content on Hover or Focus), in every mode, the card is dismissable (Escape
hides it wherever focus is) and persistent (no timeout), and it never hides while the pointer is
over the plot or the card. Escape on the focused plot hides only the card and is consumed and stopped
there, so an enclosing dialog stays open, a native <dialog> or one that listens in React as
Dialog does (fixed in the week 12 review); with focus elsewhere, Escape hides a card the pointer opened and is not
consumed, so it also reaches an enclosing dialog (kept as built by M41). The card follows the point,
band or slice under the pointer, though, so it cannot be held on one point: it is not "hoverable" in
1.4.13's sense. If the island fails to load, the server-rendered
chart and its data table stay.
What a page downloads for it, the island with ChartTooltip and cx, measured 1,580 B gzip on
2026-10-20 (size-limit's method, React external). Week 10's was 1,080 B; M40 added Escape for a
card the pointer opened (+56 B), column mode (+114 B), the resolved xDirection="auto" (+41 B), the
compact payload for long series (+148 B) and the week 11 review's fixes (+18 B: Escape on the plot
keeps an enclosing dialog open, and hiding the card empties the live region), 1,457 B; M41's item
mode for DonutChart added 112 B, and the week 12 review's Escape fix (the key stopped on the plot, so
a dialog that listens in React stays open) 11 B more. §17.2's island figure applies to this client
graph: 1.7 KiB, the measurement + 10 % (1,580 B × 1.1 = 1,738 B; M41).
With interaction="none", AreaChart, BarChart and DonutChart render no island and make no
keyboard claim: their hover states are CSS only. Pass tableView="details" (a native disclosure,
one Tab stop, no JavaScript) wherever keyboard users then need the data table.
The data table
Every chart renders a <table> of its data on the server: one row per datum, with the formatted
values. It is the accessible alternative to the picture, and it is visually hidden by default
(tableView="hidden"), because Figma draws charts without a table. tableView="details" puts it
under the plot in a native <details> disclosure that anyone can open, with no JavaScript. Pass
your own ChartFrame table when you draw the chart yourself.
The comparison series (dashed, chart/neutral) is decorative: the table carries its values.
A third-party renderer
ChartFrame takes any renderer as its child. With a third-party child,
pass the table yourself (pre-formatted strings), interaction="none" and, when the library draws
its own axes, axes="none". The frame then guarantees the caption, the data table, the legend and
the four statuses. It makes no keyboard claim for the points: keyboard access to them is the
library's.
Recharts 3 renders no SVG on the server (r6 §2), so its chart is a client component that paints after hydration. The caption and the data table are still server HTML. This snippet shows the shape; Recharts is not a dependency of Nizivo, and no Nizivo check compiles it:
// sales-plot.tsx
'use client';
import {
Area,
AreaChart as RechartsAreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
} from 'recharts';
export function SalesPlot({ data }: { data: { day: string; sales: number }[] }) {
return (
<ResponsiveContainer width="100%" height="100%">
<RechartsAreaChart data={data}>
<XAxis dataKey="day" />
<Tooltip />
<Area dataKey="sales" />
</RechartsAreaChart>
</ResponsiveContainer>
);
}// page.tsx (a Server Component)
import { ChartFrame } from '@nizivo/charts/chart-frame';
import { SalesPlot } from './sales-plot';
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export default async function Page() {
const sales = await getSales(); // [{ day: 'Oct 1', sales: 2140 }, …]
return (
<ChartFrame
title="Sales"
axes="none"
interaction="none"
table={{
columns: ['Day', 'Sales'],
rows: sales.map((d) => [d.day, usd.format(d.sales)]),
}}
>
<SalesPlot data={sales} />
</ChartFrame>
);
}What the frame guarantees around a third-party child is checked on the server markup by
chart-frame.conformance.test.tsx in packages/charts/test/conformance (a stand-in third-party
<svg> child: the child reaches the plot unchanged, and the frame adds no Tab stop and no live
region in any status). fixtures/next/app/chart-third-party is the same shape in the Next.js
fixture, recorded in all four Next.js modes in week 10: the page ships no charts module
(fixtures/next/RESULTS.md, "Week 10").
To line a custom renderer up with the frame's tick labels, the barrel exports the scales and tick
helpers Nizivo's renderers use: linear, band, point, valueDomain, dateTicks, everyNth,
tickEvery, tickLabels, isPlainDate, plainDateValue and plainDateFormat, and the
ScaleLike type, which d3 scales satisfy unchanged. No Nizivo chart takes a scale prop: the
scale interfaces are the seam for a renderer you write inside ChartFrame, which can use these
scales or d3's.
CSP
No part writes a style attribute into its server HTML: the example test
(examples.test.tsx in packages/charts/test/ssr) and the conformance suite fail on any style=.
Charts' static output therefore renders under CSP Profile B (style-src-attr 'none', §19.3). After
hydration, the island positions its crosshair, dots and card through the CSSOM (element.style),
which style-src-attr 'none' does not block; chart-interaction-csp.spec.tsx in packages/charts/test/browser
checks that under that policy in Chromium, WebKit and Firefox. A third-party renderer's own style
attributes are outside this guarantee.
Right to left
Time and value axes do not mirror by default: xDirection is 'ltr'. The caption, the legend and
the y labels follow the page's dir whatever xDirection is.
xDirection="rtl"mirrors the plot (scale: -1 1on the SVG) and the x labels, on every page.xDirection="auto"mirrors them when the chart's HTML directionality is right to left (adirattribute, CSS:dir(rtl)), and draws exactly as'ltr'otherwise.
Either way the mirroring is CSS on the server HTML, so a right-to-left page shows the mirrored
chart, marks and x labels, before any script runs, and nothing moves when the island hydrates
(chart-interaction.spec.tsx checks that no box moves). Until then the plot has no interaction, as
on any page. After hydration the island reads the plot's resolved direction once, so the
crosshair, the pointer and the arrow keys follow the screen (ArrowRight goes back in time). Two
limits: 'auto' follows the dir attribute, not the CSS direction property, so a page that sets
only direction: rtl in CSS gets a left-to-right chart; and the island reads the direction once.
A new xDirection prop remounts the island (AreaChart and BarChart key it by the prop), so it
follows; a chart whose dir changes at runtime under 'auto' must be remounted. An engine without :dir() drops only
the 'auto' mirroring; 'rtl' has rules of its own.
Reduced motion
Lines and areas reveal by clip-path, bars grow from the bottom of the plot and the donut fades in,
all over --nz-duration-chart-draw: 700 ms, and 0 ms under prefers-reduced-motion: reduce or
data-nz-motion="reduce" (Motion). Nothing uses pathLength.
The dependency rule
@nizivo/charts imports nothing from @nizivo/react, and @nizivo/react imports nothing from
@nizivo/charts, so either can be used without the other. Charts' runtime imports are react,
react-dom, d3-shape and d3-array only (dependency-rule.test.ts in packages/charts/test/unit and
an oxlint rule; react's test/unit/module-graph.test.ts checks the other direction). The few
helpers both need (cx, mergeProps, the render prop) are copies that nz gen writes from
@nizivo/react's source into _shared in packages/charts/src, and nz gen --check fails when a copy
is stale. @nizivo/react reaches charts only through element slots: KpiTile takes
chart={<Sparkline … />} and TrendCard chart={<AreaChart … />}
(Dashboard).
Sizes
Gzip, React and ReactDOM external, d3 included, measured with size-limit over the checks of
packages/tooling/size/checks.ts (the ones node packages/tooling/size/measure.ts runs) on 2026-10-20 and enforced by pnpm size: the limit is the measurement + 10%, never above the
entry's §17.2 figure (CHART_TARGETS_KIB in packages/tooling/size/method.ts;
packages/react/SIZES.md records the limits). The figures §17.2 had no number for until week 11
were confirmed by the maintainer in M40.
| Entry | Measured | §17.2 figure | Source of the figure |
|---|---|---|---|
The island with ChartTooltip and cx | 1,580 B | 1.7 KiB | M41: the client graph, measured + 10 % |
@nizivo/charts/area-chart | 6,692 B | 7.1 KiB | M40: the framed subpath, measured + 10 % |
@nizivo/charts/bar-chart | 5,380 B | 5.7 KiB | M40: 3.6 confirmed, then raised for column mode |
@nizivo/charts/donut-chart | 4,856 B | 5.3 KiB | M41: raised from 3.2 for item mode, measured + 10 % |
@nizivo/charts/sparkline | 1,618 B | 2 KiB | §17.2 (≤ 2) |
@nizivo/charts/chart-frame | 1,793 B | 2 KiB | M40: confirmed |
@nizivo/charts/legend | 870 B | 1 KiB | M40: confirmed |
@nizivo/charts/chart-tooltip | 413 B | 1 KiB | M40: confirmed |
@nizivo/charts/styles.css | 3,473 B | 5 KiB | M40: confirmed |
@nizivo/charts (the barrel, every chart) | 9,274 B | 12 KiB | §17.2, "all charts" |
The AreaChart, BarChart and DonutChart subpaths include the island's client graph. Under React Server
Components a page pays that graph only: the renderers stay on the server. The Next.js fixture
records /chart-area at +1,703 B gzip under Turbopack against /button-only (+1,588 B in week 11
and +1,230 B in week 10, before M40 and M41 grew the island; fixtures/next/RESULTS.md, "Week 12").
Dashboard
The dashboard parts of @nizivo/react: NumberValue, Metric, KpiTile, TrendCard, RankedList and CountUp. Server Components with one small client part, one loading, empty and error contract, and charts through element slots.
ChartFrame
The <figure> every chart renders in, around any renderer, Nizivo's or a third party's. It owns the caption, the legend slot, the HTML tick labels, the server-rendered data table and the loading, empty and error statuses.