Charts

Four micro-primitives — sparkline, progress ring, gauge and mini donut — built from the SVG components, styled with the props everything else here takes, and cheap enough to put one in every row of a ten-thousand-row grid.

Not a chart library

These are the small, dense drawings a dashboard is made of, and nothing more: no axes, no legends, no data transformations. What they give you instead is that a chart is a Box — its colour, its size, its dark mode, its hover state and its breakpoints are the props you already know, and there is no second styling system to learn or to theme. For a real chart with axes and a tooltip, reach for Recharts and wrap it in a ChartContainer, which is further down this page; these are for the twenty places a dashboard needs a shape rather than a chart.
Sparkline
JSX
<Flex gap={8} ai="center" flexWrap="wrap">
  <Sparkline data={[4,9,6,12,10,15,13,18]} width="7rem" color="sky-500" />
  <Sparkline
    data={[4,9,6,12,10,15,13,18]}
    width="7rem"
    variant="area"
    color="violet-500"
  />
  <Sparkline
    data={[4,9,6,12,10,15,13,18]}
    width="7rem"
    variant="bar"
    color="emerald-500"
  />
</Flex>

A sparkline fills its box

A sparkline is the one primitive that is not drawn to scale: it is 100% wide and stretches to whatever you give it, which is what makes it usable in a table cell of unknown width. The line stays one width thick anyway, because vectorEffect="non-scaling-stroke" is on by default — the property SVG has for exactly this, and an ordinary prop, so vectorEffect="none" turns it off. Everything else is inherited from the <svg>: color paints it (the default stroke is currentColor), strokeWidth thickens it, and both take a hover, a theme and a breakpoint.
One scale for many rows
Scaled to itself
Shared scale
JSX
// Each sparkline scales to its own data by default — good alone, misleading in a column.
<Sparkline data={[4, 9, 6, 12]} />

// min and max fix the axis, so two rows can be compared at a glance.
<Sparkline data={[4, 9, 6, 12]} min={0} max={20} />
<Sparkline data={[2, 3, 2, 4]} min={0} max={20} />
A gradient is a value now
JSX
<Sparkline
  data={[4,9,6,12,10,15,13,18]}
  variant="area"
  width="16rem"
  height="4rem"
  stroke="url(#trend)"
  fill="url(#trend)"
>
  <Defs>
    <LinearGradient
      id="trend"
      x1="0"
      y1="0"
      x2="1"
      y2="0"
    >
      <Stop offset="0%" stopColor="currentColor" color="sky-500" />
      <Stop offset="100%" stopColor="currentColor" color="violet-500" />
    </LinearGradient>
  </Defs>
</Sparkline>

…which it was not before

fill and stroke used to take a colour token and nothing else, so a gradient had to be written as an attribute — props={{ fill: 'url(#sky)' }} — which put the paint outside the theme system and outside every pseudo-class. Both props now also accept a reference to something the document defines (url(#trend)) or a variable somebody else declared (var(--chart-1)), so a gradient fill can change on hover and per theme like any other value. clipPath takes a url(#…) the same way. A typo still produces nothing at all rather than a broken declaration: the definition names the two shapes it accepts.
Progress ring
JSX
<Flex gap={8} ai="center" flexWrap="wrap">
  <ProgressRing value={0.25} color="sky-500" />
  <ProgressRing value={0.62} color="emerald-500" thickness={16} />
  <ProgressRing value={0.9} color="amber-500" thickness={6}>
    <SvgText
      x={50}
      y={57}
      textAnchor="middle"
      fontSize={26}
      fill="amber-500"
      stroke="none"
    >
      90
    </SvgText>
  </ProgressRing>
</Flex>
The arc eases with no animation code
JSX
const [value, setValue] = useState(0.35);

<Flex gap={6} ai="center">
  <ProgressRing value={value} color="violet-500" width="5rem" height="5rem" label="Progress" />
  <Button onClick={() => setValue(0.75)}>75%</Button>
</Flex>

Why that moves

The filled part of a ring is a dash on its outline, and a dash length is an ordinary style prop — so it lands in a CSS class, and every shape inside an <svg> already transitions. Setting a number is all the JavaScript there is; the easing is the stylesheet, and it stops on its own for a visitor who asked for prefers-reduced-motion.
Gauge
JSX
<Flex gap={8} ai="center" flexWrap="wrap">
  <Gauge value={0.4} color="sky-500" />
  <Gauge
    value={0.75}
    color="rose-500"
    sweep={180}
    start={270}
  >
    <SvgText
      x={50}
      y={48}
      textAnchor="middle"
      fontSize={22}
      fill="rose-500"
      stroke="none"
    >
      75%
    </SvgText>
  </Gauge>
  <Gauge
    value={0.55}
    color="emerald-500"
    sweep={360}
    thickness={6}
  />
</Flex>

A dial is an arc you choose

sweep is how far round it goes and start is where it begins, in degrees clockwise from twelve o'clock — three quarters of a turn from the bottom left by default, a half turn for a speedometer, a whole turn for a ring. Both are constants of the shape rather than of the data, so every gauge of the same shape shares one path string.
Mini donut
JSX
<Flex gap={8} ai="center" flexWrap="wrap">
  <MiniDonut data={[5,3,2]} />
  <MiniDonut
    data={[8,5,3,2,1]}
    thickness={12}
    width="4rem"
    height="4rem"
  />
  <MiniDonut
    data={[6,4]}
    colors={["violet-500","violet-200"]}
    thickness={30}
  />
</Flex>

Colours, and where they come from

A donut is the one primitive that needs more than one colour, so it takes a list and cycles it. Each entry is anything the fill prop accepts — a token like sky-500, or var(--chart-1) if your design system publishes a chart palette. The values need no total: each is drawn as its share of the whole.
Ten thousand rows, one sparkline each
Account
Revenue
Last 12 months
Share of target
JSX
// A cell renderer is a component: define it outside the render, or every scroll remounts the column.
function TrendCell({ cell }: { cell: { row: { data: Row } } }) {
  return (
    <Flex px={3} ai="center" height="fit">
      <Sparkline data={cell.row.data.trend} color="emerald-500" />
    </Flex>
  );
}

<DataGrid
  data={rows}
  def={{
    rowKey: 'id',
    rowHeight: 36,
    columns: [
      { key: 'name', header: 'Account' },
      { key: 'revenue', header: 'Revenue', align: 'right' },
      { key: 'trend', header: 'Last 12 months', width: 180, Cell: TrendCell },
    ],
  }}
/>

Why ten thousand rows is affordable

Two different answers, and the split is the whole trick. A sparkline's shape is the d attribute, which the styling engine never sees — ten thousand different shapes generate no CSS at all, and what is a style prop (the colour, the width) is shared, so the rows share one rule each. A ring's fill, on the other hand, has to be a style prop to be able to transition, so it lands in a class name — and the fraction is rounded to half a percent, which caps a column of percentages at a couple of hundred rules instead of one per row. Half a percent of a 48px ring is a third of a pixel.

A CSS variable is a Box prop

Every prop on this page becomes a CSS declaration inside a class. vars is the one whose names come out of the value: vars={{ 'color-revenue': 'sky-500' }} declares --color-revenue on the element, and everything inside it inherits it — including markup this library never rendered. Because it is an ordinary prop it nests inside theme, hover and a breakpoint like all the others, and it lands in a class, so two subtrees declaring the same palette share one rule rather than each carrying a <style> tag of its own.
A Recharts chart, themed by the page it sits on
JSX
import { ChartContainer } from '@cronocode/react-box/components/chart';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';

<ChartContainer
  series={['revenue', 'cost']}
  vars={{ 'chart-grid': 'slate-200', 'chart-label': 'slate-500' }}
  theme={{ dark: { vars: { 'chart-grid': 'slate-800', 'chart-label': 'slate-400' } } }}
  height={60}
>
  <ResponsiveContainer width="100%" height="100%">
    <AreaChart data={months}>
      <CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" vertical={false} />
      <XAxis dataKey="month" stroke="var(--chart-label)" fontSize={12} />
      <YAxis stroke="var(--chart-label)" fontSize={12} width={36} />
      <Area dataKey="revenue" stroke="var(--color-revenue)" fill="var(--color-revenue)" fillOpacity={0.15} strokeWidth={2} />
      <Area dataKey="cost" stroke="var(--color-cost)" fill="var(--color-cost)" fillOpacity={0.15} strokeWidth={2} />
    </AreaChart>
  </ResponsiveContainer>
</ChartContainer>

The chart names no colour

Flip the theme in the header and every line above changes with it — the chart code does not. That is the whole of ChartContainer: it declares --chart-1--chart-6 in both themes and one --color-<series> per name you give it, so stroke="var(--color-revenue)" is all the chart ever says about paint. The two names it does not know about — the grid and the axis labels — are the same mechanism spelled out by hand, which is what vars is for.
The names are deliberately the ones the ecosystem already uses, so a chart lifted out of shadcn's charts works unchanged. What is different is where they live: there is no <style> tag per chart and no id to scope it with, because a Box prop is already scoped to its element — and two tiles with the same series share the rule.

Naming a picture of numbers

Every primitive follows Svg's rule, and a chart is the case where it matters most: with no label the drawing is aria-hidden, and with one it is role="img" with that name. Both are right in different places. A sparkline beside the number it summarises is decoration — leave it unnamed, or a screen reader reads the row twice. A sparkline that is the only thing in a cell is the data, so give it a label that says what a sighted reader gets from the shape: label="Revenue, rising 12% over six months", not label="Chart".