Chart

Renders a TanStack Charts definition in the theme's series palette, with a tooltip and a legend that toggles series.

View as Markdown
Visitors
January to June
const config = {  desktop: { label: "Desktop" },  mobile: { label: "Mobile" },} satisfies ChartConfig;
const definition = useMemo(  () =>    defineChart({      marks: [        crosshair({          x: { band: { fill: colors.fillSubtle, fillOpacity: 1, radius: 4 } },          y: false,        }),        barY(rows, {          x: "month",          y: "visitors",          z: "device",          color: "device",          layout: group({ scale: chartGroupScale(weights) }),          radius: 4,          inset: 1,        }),      ],      scales: {        x: { scale: () => scaleBand().padding(0.25) },        y: { scale: scaleLinear().domain([0, max]), grid: true },      },      color: chartColor(config),      focus: "group-x",      tooltip,    }),  [max, rows, weights]);
<ChartLegendContent config={config} onValueChange={setShown} value={shown} /><Chart  animate={animate}  ariaLabel="Monthly visitors"  config={config}  definition={definition}  height={240}/>;

Installation

$ npx shadcn@latest add @constella/chart

Usage

import { Chart, chartColor } from "@/components/tanstack/chart";import type { ChartConfig } from "@/components/tanstack/chart";

Build the chart with TanStack Charts' defineChart and pass it to Chart with an ariaLabel that names the metric and period. Marks, scales, focus and the tooltip's behavior stay in the definition; Chart draws it in the theme's palette, with axes, ticks and grid lines in textMuted.

<Chart  ariaLabel="Monthly visitors, January to June"  config={config}  definition={definition}  height={240}/>

Keep the definition outside the component, or in a useMemo on the values it reads. A new definition rebuilds the chart.

Composition

Chart└── ChartTooltipContent    └── ChartTooltip        ├── ChartTooltipTitle        └── ChartTooltipRow            ├── ChartIndicator            ├── ChartTooltipLabel            └── ChartTooltipValueChartLegendContent└── ChartLegend    └── ChartLegendItem        └── ChartIndicator

Config

Name each series once in a ChartConfig, keyed by its value in the data. Pass chartColor(config) as the definition's color, and the same config to Chart and ChartLegendContent, so the tooltip and legend name it and every part paints it alike. Hiding a series never repaints the rest.

const config = {  desktop: { label: "Desktop" },  mobile: { label: "Mobile", color: colors.chart5 },} satisfies ChartConfig;

A series without a color takes the next in the palette, in the config's order. Past six, fold the rest into one gray "Other" with chartFold, which sums their rows that share every other field:

const { config, rows } = chartFold(allConfig, allRows, {  series: "device",  value: "visitors",});
SeriesTokenHue
1colors.chart1Indigo
2colors.chart2Teal
3colors.chart3Violet
4colors.chart4Cyan
5colors.chart5Orange
6colors.chart6Pink

Tooltip

Add TanStack's tooltip to the definition and Chart renders it as ChartTooltipContent, naming each series from config. Leave tooltip out for none. To change its indicator or drop its title, render it yourself:

<Chart  renderTooltipBody={({ content }) => (    <ChartTooltipContent      config={config}      content={content}      hideLabel      indicator="dot"    />  )}  {...props}/>

To change what it holds, compose the parts, here with a total under the stack.

Visitors
January to June, stacked
<Chart  renderTooltipBody={({ points, primaryPoint }) => (    <ChartTooltip>      <ChartTooltipTitle>{points[0]?.datum.month}</ChartTooltipTitle>      {points.map((point) => (        <ChartTooltipRow active={point === primaryPoint} key={point.key}>          <ChartIndicator color={point.color} variant="line" />          <ChartTooltipLabel>            {config[point.datum.device].label}          </ChartTooltipLabel>          <ChartTooltipValue>{point.datum.visitors}</ChartTooltipValue>        </ChartTooltipRow>      ))}      <ChartTooltipRow>        <ChartTooltipLabel>Total</ChartTooltipLabel>        <ChartTooltipValue>{total(points)}</ChartTooltipValue>      </ChartTooltipRow>    </ChartTooltip>  )}  {...props}/>

Legend

Use ChartLegendContent with config for a toggle per series; value lists the shown ones. Filter the definition's rows by it.

Visitors by browser
January to June
<ChartLegendContent  config={config}  onValueChange={(next) => {    if (next.length > 0) {      setShown(next);    }  }}  value={shown}/>

For your own items, compose ChartLegend, ChartLegendItem and ChartIndicator.

<ChartLegend onValueChange={setShown} value={shown}>  <ChartLegendItem value="chrome">    <ChartIndicator color={colors.chart1} />    Chrome  </ChartLegendItem></ChartLegend>

Animate

Ease the values with useChartTween and pass animate to Chart for a change from a click, never a key press.

const [animate, setAnimate] = useState(false);const target = useMemo(  () =>    Object.fromEntries(      rows.map((row) => [        `${row.month}-${row.device}`,        shown.includes(row.device) ? row.visitors : 0,      ])    ),  [shown]);const values = useChartTween(target, animate);
<ChartLegendContent  config={config}  onValueChange={(next, { event }) => {    setShown(next);    // A key press reports a `detail` of 0.    setAnimate(event instanceof MouseEvent && event.detail > 0);  }}  value={shown}/><Chart animate={animate} definition={definition} {...props} />;

Set the axis to where the change ends, and scale the eased values onto it. An axis that eases re-ticks every frame. animate then fades in the tick labels that arrive, move or change.

const { max = target.max } = useChartTween(target, animate);
barY(rows.map((row) => ({ ...row, visitors: (row.visitors * target.max) / max })), …);// scales.y{ scale: scaleLinear().domain([0, target.max]), grid: true }

Group Scale

For grouped bars, ease each series' slot with chartGroupScale. Give it a weight from 0 to 1 per series, and put a closing series' rows first, so its bars sit under the ones that widen over them.

layout: group({  scale: chartGroupScale([    ["desktop", values.desktop],    ["mobile", values.mobile],  ]),}),

Line

Visitors
January to June
defineChart({  marks: [    crosshair({ x: true, y: false }),    // A line per series, so a leaving one can fade alone.    ...opacities.map(([device, opacity]) =>      lineY(        rows.filter((row) => row.device === device),        {          id: `line-${device}`,          x: "month",          y: "visitors",          color: "device",          strokeOpacity: opacity,          strokeWidth: 2,        }      )    ),  ],  clip: true,  scales: {    x: { scale: () => scalePoint().padding(0.25) },    y: { scale: scaleLinear().domain(domain), grid: true },  },  color: chartColor(config),  focus: "group-x",  maxFocusDistance: Number.POSITIVE_INFINITY,  tooltip,});

API Reference

Chart takes TanStack Charts' React Chart props, with sx for className and style. The rest wrap a single element and take sx.

PartAdds
Chartconfig, naming series in the default tooltip; renderTooltipBody, ChartTooltipContent by default; animate, fading in tick labels a change adds or moves. Sets every axis's tickLabels.fontSize to 12 unless the definition sets its own
ChartTooltipContentcontent, TanStack's title and rows, or its text; config; indicator, line by default; hideLabel
ChartTooltipRowactive, the series under the pointer or keyboard
ChartIndicatorcolor, the series' paint; variant, dot or line; hollow in a hidden legend item
ChartLegendContentconfig, and Chart Legend's props
ChartLegendToggle Group's props, always multiple
ChartLegendItemToggle Group Item's props
chartColorconfig in, the definition's color out: every series' domain and paint
chartFoldconfig, rows, and { series, value }, the rows' field names; label, Other by default; the first six series and one other, summed
chartTickFormatlocale in, an axis's ticks.format out: compact counts (12M), whole numbers only
useChartTweentarget, values by key; animate; returns them eased over dialog on out, or at once when animate is false or motion is reduced
chartGroupScaleweights, each series' share of a slot from 0 to 1; padding, 0 by default, as each bar's inset: 1 already sets them 2px apart; a band scale for group({ scale })