Skip to content
Nizivo UI

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

PartImportKindShips client JS under React Server Components
ChartFrame@nizivo/charts/chart-frameserverNever
AreaChart@nizivo/charts/area-chartsplitThe ChartInteraction island, when status is 'ready' and interaction is 'nizivo' (default)
BarChart@nizivo/charts/bar-chartsplitThe ChartInteraction island in column mode, when status is 'ready' and interaction is 'nizivo' (default)
DonutChart@nizivo/charts/donut-chartsplitThe ChartInteraction island in item mode, when status is 'ready' and interaction is 'nizivo' (default)
FunnelChart@nizivo/charts/funnel-chartserverNever
Heatmap@nizivo/charts/heatmapserverNever
Sparkline@nizivo/charts/sparklineserverNever
Legend@nizivo/charts/legendserverNever
ChartTooltip@nizivo/charts/chart-tooltipserverNever

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/tokens
import '@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 100 viewBox stretched over the plot (preserveAspectRatio="none"), with vector-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), and stroke.spec.tsx in packages/charts/test/browser keeps 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_DIGITS in packages/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) => value function. AreaChart and BarChart take x and y, DonutChart category and value, AreaChart's comparison compare, and Sparkline an optional y.
  • Every *Format prop is plain Intl.NumberFormatOptions or Intl.DateTimeFormatOptions, as @nizivo/react's Delta and Meter take them; there are no presets. format defaults to Intl.NumberFormat's own options ({}) in every chart.
  • The cartesian charts (AreaChart, BarChart) share CartesianChartOptions: xTicks: { every?, max? } for the x labels and yTicks (about how many y labels, default 4).
  • A chart's strings are XLabels extends ChartFrameLabels, with English defaults in xLabels (areaChartLabels), and labels takes Partial<XLabels>.
  • A chart's props (AreaChartProps and 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, tableView and 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-highlighted on 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-highlighted goes 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.ListFormat gives the chart's locale.

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 1 on the SVG) and the x labels, on every page.
  • xDirection="auto" mirrors them when the chart's HTML directionality is right to left (a dir attribute, 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.

EntryMeasured§17.2 figureSource of the figure
The island with ChartTooltip and cx1,580 B1.7 KiBM41: the client graph, measured + 10 %
@nizivo/charts/area-chart6,692 B7.1 KiBM40: the framed subpath, measured + 10 %
@nizivo/charts/bar-chart5,380 B5.7 KiBM40: 3.6 confirmed, then raised for column mode
@nizivo/charts/donut-chart4,856 B5.3 KiBM41: raised from 3.2 for item mode, measured + 10 %
@nizivo/charts/sparkline1,618 B2 KiB§17.2 (≤ 2)
@nizivo/charts/chart-frame1,793 B2 KiBM40: confirmed
@nizivo/charts/legend870 B1 KiBM40: confirmed
@nizivo/charts/chart-tooltip413 B1 KiBM40: confirmed
@nizivo/charts/styles.css3,473 B5 KiBM40: confirmed
@nizivo/charts (the barrel, every chart)9,274 B12 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").

On this page