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 '@box-kite/react/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".

Sparkline props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
datarequiredreadonly number[]—The numbers to draw, oldest first.
variant'line' | 'area' | 'bar''line'line (default), area — the line with the space beneath it filled — or bar.
minnumber—Fix the ends of the value axis. Without them the line uses its own lowest and highest value, so every row of a table is scaled to itself; with them a column of sparklines is comparable.
maxnumber—The top of the value axis — see min, which it is written beside.
preserveAspectRatiostring—How the drawing is fitted when the box it is given is a different shape.
widthstring | number—The width attribute: a number in user units, or any CSS length — "100%", "12rem".
heightstring | number—The height attribute: a number in user units, or any CSS length.
labelstring—Names the drawing — role="img" and this text. Without one the <svg> is aria-hidden.
childrenReact.ReactNode—What is drawn on top of the shape: the number inside a ring, a gradient in a <Defs>. SVG content, not a render prop.

Sparkline accessibility

  • The <svg> is aria-hidden unless it is given a label, because a sparkline usually repeats a number that is already in the row beside it.
  • A chart is not a substitute for the values: where the shape *is* the information, give it a label saying what it shows, and keep the numbers reachable in text.
Swept with axe on every release, in 2 states: Sparkline, Sparkline (decoration). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

Sparkline style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
sparkline
sparkline.line
sparkline.area
sparkline.bar

ProgressRing props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
valuerequirednumber—How full the arc is, from 0 to 1. Rounded to half a percent — see ChartUtils.FRACTION_STEP.
thicknessnumber10The width of the ring in view units, out of the 100 the box is wide.
trackOpacityNonNullable<BoxStyleProps['strokeOpacity']>—The unfilled part of the ring, as the same colour faded — so the track follows the theme too. Default 0.2, from the style node.
preserveAspectRatiostring—How the drawing is fitted when the box it is given is a different shape.
widthstring | number—The width attribute: a number in user units, or any CSS length — "100%", "12rem".
heightstring | number—The height attribute: a number in user units, or any CSS length.
labelstring—Names the drawing — role="img" and this text. Without one the <svg> is aria-hidden.
childrenReact.ReactNode—What is drawn on top of the shape: the number inside a ring, a gradient in a <Defs>. SVG content, not a render prop.

ProgressRing accessibility

  • aria-hidden with no label, and a label makes it role="img". For progress a user is *waiting* on, put the value in text or in a role="progressbar" of your own — an image role does not announce a change.
Swept with axe on every release, in this state: ProgressRing. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

ProgressRing style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
progressRing
progressRing.track
progressRing.arc

Gauge props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
sweepnumber270How far round the dial goes, in degrees. Three quarters of a turn by default.
startnumber225Where it starts, in degrees clockwise from twelve o'clock. Bottom left by default.
valuerequirednumber—How full the arc is, from 0 to 1. Rounded to half a percent — see ChartUtils.FRACTION_STEP.
thicknessnumber10The width of the ring in view units, out of the 100 the box is wide.
trackOpacityNonNullable<BoxStyleProps['strokeOpacity']>—The unfilled part of the ring, as the same colour faded — so the track follows the theme too. Default 0.2, from the style node.
preserveAspectRatiostring—How the drawing is fitted when the box it is given is a different shape.
widthstring | number—The width attribute: a number in user units, or any CSS length — "100%", "12rem".
heightstring | number—The height attribute: a number in user units, or any CSS length.
labelstring—Names the drawing — role="img" and this text. Without one the <svg> is aria-hidden.
childrenReact.ReactNode—What is drawn on top of the shape: the number inside a ring, a gradient in a <Defs>. SVG content, not a render prop.

Gauge accessibility

  • aria-hidden with no label, and a label makes it role="img". The value itself belongs in text: a dial's angle is not readable by anyone who cannot see it.
Swept with axe on every release, in this state: Gauge. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

Gauge style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
gaugeextends progressRing
gauge.track
gauge.arc

MiniDonut props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
datarequiredreadonly number[]—One value per segment. Each is drawn as its share of the whole, so they need no total.
colorsreadonly Paint[]DEFAULT_COLORSOne colour per segment, cycled if there are fewer than there are values.
thicknessnumber20The width of the ring in view units, out of the 100 the box is wide.
preserveAspectRatiostring—How the drawing is fitted when the box it is given is a different shape.
widthstring | number—The width attribute: a number in user units, or any CSS length — "100%", "12rem".
heightstring | number—The height attribute: a number in user units, or any CSS length.
labelstring—Names the drawing — role="img" and this text. Without one the <svg> is aria-hidden.
childrenReact.ReactNode—What is drawn on top of the shape: the number inside a ring, a gradient in a <Defs>. SVG content, not a render prop.

MiniDonut accessibility

  • aria-hidden with no label, and a label makes it role="img". Segments are told apart by colour alone, so the breakdown has to exist somewhere a reader can get at it.
Swept with axe on every release, in this state: MiniDonut. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

MiniDonut style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
miniDonut
miniDonut.segment

ChartContainer props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
PropTypeDefaultWhat it does
seriesChartSeries—The series drawn inside, and what colour each of them is.

ChartContainer accessibility

  • It is a <div> with variables on it — no role, no name, nothing in the accessibility tree. Whatever the chart library renders inside owns its own accessibility.
Swept with axe on every release, in this state: ChartContainer. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

ChartContainer style tree

Every part the component draws is a node with a name, so a default can be restyled with Box.components() instead of a selector — and a variant is a name too.
chartContainer