Chart

GithubStorybook

Chart draws data as composable marks — lines, bars, areas and radial arcs — on shared, automatically laid out axes.

Importing

import { Chart, arc, area, bar, line, sankey } from '@customerio/pluma-components/charts/react';

The mark factories — line, bar, area, arc, autoPositionedLabel and sankey — are plain functions rather than components, so both frameworks import them from the same place and build the same array.

Usage

A chart needs an ariaLabel and at least one mark. Each mark takes the rows it draws and the names of the fields to read.

The label becomes the chart's accessible name, which is how a test finds it — getByRole('img', { name }). Give two charts in one panel names where neither contains the other, or a query for one of them matches both.

The examples below are complete but for useSampleDataset — that is this site's own loader for Observable's sample data, standing in for wherever your rows come from.

Loading editor

In React, wrap the marks array in useMemo. The definition it produces is what the renderer diffs against, so a new array on every render rebuilds the scene on every render.

That is the whole of building a chart. Everything past it is one of five subjects, each on its own page:

Beyond line and bar

definition reaches the TanStack Charts definition underneath the chart, for what the props don't cover — the mark types, transforms and faceting this iteration doesn't wrap, and options like clip and cursor.

An object is laid over the definition the chart built from its other props, so everything you didn't mention stays as it was. Options merge all the way down; an array, a scale or a function replaces what it lands on.

<Chart
	ariaLabel="Email opens per day"
	marks={marks}
	yAxis={{ label: 'Opens' }}
	definition={{ y: { axis: { ticks: { count: 3 } } } }}
/>

That axis keeps the scale and the tick format the chart inferred for it — only the count moves.

A function is handed that same definition and returns the one to draw, for what a merge can't say — adding to the marks rather than replacing them, or answering with a definition of your own:

import { dot } from '@tanstack/charts/dot';

<Chart
	ariaLabel="Email opens per day"
	marks={[line({ data: rows, x: 'date', y: 'opens' })]}
	yAxis={{ label: 'Opens' }}
	definition={(defaults) => ({
		...defaults,
		marks: [...defaults.marks, dot(outliers, { x: 'date', y: 'opens', r: 5 })],
	})}
/>;

In React, memoize the object or the function the same way you memoize marks — a new one on every render rebuilds the scene on every render.

The function is the only form that replaces. An object inherits every key it doesn't mention — x, y, theme, motion, focus, focusRing, maxFocusDistance and tooltip included — so a whole definition handed over as an object still picks up whatever it left out. For a radial or sankey definition that means the cartesian axes it deliberately has none of.

buildChartDefinition builds the same definition outside a chart, for code that needs one in hand before it renders.

API

PlumaChart extends Box

A longer description of the chart, for detail the label can't carry.

The chart's accessible name — what the chart shows, in the words a reader would use. Required: without it the chart is an unlabelled image to a screen reader.

Derives the height from the measured width instead of fixing it. Ignored when height is set.

The TanStack Charts definition underneath the chart, for what the props above don't reach — clip, cursor, other mark types, transforms, faceting, custom scales.

An object is laid over the definition Pluma built from the other props, option by option, so a chart keeps its defaults and sets only what it came for: definition={{ clip: true }} is a chart that clips to its plot and nothing else changed. Objects merge all the way down, and anything else — an array, a scale, a function — replaces what was under it.

A function is handed Pluma's definition and returns the one to draw, for what a merge can't say: adding to marks rather than replacing them, reading a default before changing it, or returning a definition of your own and ignoring Pluma's altogether. It is the only form that replaces. An object inherits every key it doesn't mention, x, y, theme, motion, focus, focusRing, maxFocusDistance and tooltip included — so a whole definition passed as an object still picks up whatever it left out, which for a radial or sankey definition means the cartesian axes it deliberately has none of.

Default:the chart's `ariaLabel`, as a slug

What the downloaded files are called, without an extension.

Default:['png', 'pdf', 'csv']

Which files the export menu offers, in the order it lists them. See ChartExportFormat.

'csv' needs rows to write, so it is dropped from the menu on a chart that has none — one whose only marks are annotations, and one whose definition is a builder function, which is free to draw marks Pluma never saw. A chart left with no formats at all draws no button.

Default:'group'

What hovering or keyboard-walking the chart focuses: the whole column at the pointer, or the single nearest point. See ChartFocusMode.

Default:320, or 40 for `variant: 'sparkline'`

The chart's height in pixels.

Which series start off the plot, by name — the value the mark's series accessor produced, as a string.

The chart tracks what the reader hides from there. Pass onHiddenSeriesChange alongside this to own the set instead.

Default:'top'

Where the legend sits. 'top' and 'bottom' are a band across the chart, with entries packed along it and wrapped as needed; 'left' and 'right' are a column beside the plot, one entry per row.

Reach for a column when the chart has a handful of series with names too long to sit side by side, or when the plot is round — a donut has no top edge for a band to belong to. It costs the plot the width the column takes, so on a narrow chart a band is still the better trade.

A column gives every series its own row and doesn't wrap, so it needs 22px of chart height per series, less 4px — an 18px row with a 4px gap between one row and the next. Past what the chart is tall the rows run out of room to be drawn in — use a band, which wraps, for more series than that.

That makes height answer to the legend on a chart with several series, which is a problem where the plot wants to be smaller than the column beside it. A radial mark has ArcOptions.outerRadius for exactly this: size the surface for the legend and the ring for itself.

Writes a number beside each legend entry's name — the series' share, its total, a rate. Called once per series with that series' values added together, the total across every series, and the share of it.

Totals cover every series the chart was given, hidden ones included, so a share stays put when the reader takes a series off the plot. A drawing already measured as a proportion of what it plots is the exception — a radial mark, and a stack with stackOffset: 'share' — where the totals cover the series still on the plot and a hidden one reads as zero, so the figures re-proportion the way the slices or the columns do. Without this the legend names series and nothing else.

Default:measured from the labels each side draws

The space around the plot, in pixels — one number for every side, or the sides to set. See ChartMargin.

A side that is set replaces the room the chart would have measured out for the labels drawn on it rather than adding to it, so it has to be wide enough to hold them — and a single number, which sets all four, has to clear every side at once. Sides left out keep what was measured for them.

That is what makes it worth having: pinning a side is how a column of charts is held to one plot width whatever each one's labels happen to be, which measuring per chart can't do. A legend beside the plot keeps the column it reserves on that side, since that is the room it is drawn in rather than space around it.

The layers to draw, from line and bar, painted in order.

Called with the point under the pointer or keyboard cursor, or null when it leaves.

Called with the full hidden set whenever a legend entry is pressed.

Called with the clicked or keyboard-activated point, or null for an empty click.

Default:the `data-viz-categorical` tokens, overridable per slot with the `--pluma-c-chart-series-*` CSS variables

The colors series are assigned from, in order.

Default:'definition'

What order the tooltip reads its rows in: the order the marks and series are declared, or the order the focused points sit on the plot. See ChartTooltipSeriesOrder.

Default:'default'

How much of the chart around the marks is drawn. 'sparkline' takes all of it away and shrinks the chart to sit beside a figure rather than under a heading. See ChartVariant.

Default:measured from the container, and re-measured as it resizes

The chart's width in pixels, for a chart that shouldn't follow its container — a fixed column in a dashboard, or an export at an exact size.

Default:false

Whether the chart draws itself in rather than appearing finished, and moves between shapes when its data changes — bars growing out of the baseline, lines revealing along their length, points and axis ticks traveling to where the new numbers put them.

Worth it where the chart is the page's subject and the reader is watching it arrive, or where it restates the same measure as the reader changes a filter or a date range: the tween is what carries which bar became which, and without it a chart that redraws is just a different picture. Leave it off for a chart that is one figure among many — a sparkline in a table, a tile on a dashboard of twelve — where a dozen entrances at once is noise, and for anything redrawing faster than it can finish.

Readers who ask for reduced motion get the finished chart with no animation, and so does a chart that has only been resized.

Default:false

Whether the chart offers its reader a download — a menu in the top corner with the chart as a picture and as the numbers behind it. See BaseChartProps.exportFormats for what it offers.

Worth it wherever the chart is the answer to a question somebody asked: a report they will send on, a dashboard tile whose numbers belong in a spreadsheet next. Leave it off where the chart is a small part of a page's furniture — a sparkline in a table, a trend behind a headline figure — which a download button in the corner would be larger than.

The code that writes a file is loaded when the reader asks for one, so a chart that offers this costs nothing until it is used.

Default:true

Whether the chart responds to the pointer and the keyboard at all — the dot it paints on the nearest point, the keyboard cursor that walks between them, and the onFocusChange / onSelect callbacks those drive.

Turning it off makes the chart a picture. Worth it only when the numbers are already readable elsewhere and the chart is too small to interrogate — a sparkline in a table cell, sitting beside the value it qualifies. It takes the tooltip with it, since a tooltip has nothing to open onto.

Default:false

Whether to render a color legend beside the plot. Has no effect until a mark declares series, since there is nothing to label before that.

The legend is interactive: each entry takes its series off the plot and puts it back when pressed again, and the axes recompute from what's left. Leave it off where the chart is a fixed summary rather than something to interrogate.

Default:false

Whether pointing at or keyboard-focusing a legend entry highlights that series on the plot.

It draws no highlight of its own: it hands the series to the same mark options the pointer already answers with, so a mark responds exactly as far as it opted in. A mark with withHoverColor swaps the whole series to its hover color; one with withUnfocusedDim recedes every other series; a mark with neither doesn't move. Turn at least one of them on, or there is nothing for this to show.

What it deliberately doesn't do is read a value: no tooltip opens and no crosshair is drawn, because an entry names a series rather than a place on the plot. Reading stays the pointer's job.

Worth it on a chart with enough series that finding one by color is work.

Default:true

Whether hovering or focusing the chart shows a tooltip for the nearest point.

The horizontal axis' guide and scale.

The vertical axis' guide and scale — or several of them, for quantities in units that have no scale in common.

Give each one an id and point a mark at it with yAxis: '<id>'; a mark that names none is measured against the first. Every axis is drawn down the left unless it asks for position: 'right', and axes sharing a side stack outward in the order they were declared. They are drawn at the same tick heights and share one grid, so scale, withGrid and tickCount come from the set rather than from each.

More than one axis makes a chart harder to read: nothing about the plot says which line belongs to which side, and the ratio between the ranges is a choice that can make almost any pair of series look correlated. Worth it for a quantity that genuinely qualifies another — a rate over the volume it is a rate of. Reach for stacked charts when they are separate subjects.

On this page