Skip to content

Chart

Build charts with theme colors, shared motion and a table of the underlying values.

import { ChartContainer, ChartDataTable, ChartLegend, ChartLegendContent, ChartMotion, ChartTooltip, ChartTooltipContent } from '@youj/react/chart'

Installation & usage

npm i @youj/react recharts

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Requires recharts, an optional dependency. Import it from @youj/react/chart.

The chart parts come from Recharts itself, so import them from recharts in your app.

Use a chart when the shape of the numbers is the point: a trend over time, a comparison between groups, a share of a whole. It's shadcn's chart, so you still write Recharts (AreaChart, Bar, Pie) and the kit supplies the container, the tooltip, the legend, the theme colors and the timing. For a trend the size of a word, in a KPI or a table cell, use a Sparkline. For one number and how it moved, use a Stat Card. When people need to sort, filter or copy the values, give them a data table.

A ChartConfig names each series and gives it a color. ChartContainer exposes each one as --color-<key>, so the marks read var(--color-revenue). Spread useChartMotion() onto each series for Youj timing.

import { Area, AreaChart, XAxis } from 'recharts'
import { ChartContainer, ChartTooltip, ChartTooltipContent, useChartMotion, type ChartConfig } from '@youj/react/chart'

const config = {
  revenue: { label: 'Revenue', color: 'var(--color-chart-1)' },
} satisfies ChartConfig

export function Revenue({ data }: { data: { month: string; revenue: number }[] }) {
  const motion = useChartMotion()
  return (
    <ChartContainer config={config} className="h-56 w-full" title="Revenue by month" summary="Revenue doubled since April.">
      <AreaChart data={data}>
        <XAxis dataKey="month" tickLine={false} axisLine={false} />
        <ChartTooltip content={<ChartTooltipContent />} />
        <Area dataKey="revenue" stroke="var(--color-revenue)" fill="var(--color-revenue)" fillOpacity={0.2} {...motion.draw(0)} />
      </AreaChart>
    </ChartContainer>
  )
}

Point the config at the chart colors, var(--color-chart-1) to var(--color-chart-5). They come from the theme: its own chart palette, which every preset tunes (Basalt's neon series, Graphite's calm blue, rose, violet, amber and teal), else one drawn from its accent. Either way light and dark and every theme preset re-color the chart with no change to your code, and every series reads at 3:1 against the surface. Axes, grid lines and cursors take the ink ramp on their own.

Size

ChartContainer is full width and aspect-video (16:9) by default, and it shrinks with its column, so a chart in a CSS grid or a flex row never pushes a phone page wider than the screen. A height class gives it a fixed height instead (className="h-64"), and an aspect class another shape (aspect-square).

Series keys

Each config key becomes a CSS variable name. A key that isn't a CSS name, like PM2.5 or Net revenue, has each character a name can't hold written as - (--color-PM2-5), and development warns once. chartColor(key) gives you a series' color for any key, so you never spell the variable yourself:

import { chartColor, type ChartConfig } from '@youj/react/chart'

const config = {
  'PM2.5': { label: 'Fine particles', color: 'var(--color-chart-1)' },
} satisfies ChartConfig

<Bar dataKey="PM2.5" fill={chartColor('PM2.5')} />

Donut center labels

A Recharts Label inside the Pie draws in the hole. Its content gets the hole's center as viewBox; write the total and its unit as two tspans on the theme's text roles:

<Pie data={traffic} dataKey="visitors" nameKey="source" innerRadius="62%" {...motion.sweep()}>
  <Label
    content={({ viewBox }) =>
      viewBox && 'cx' in viewBox ? (
        <text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
          <tspan x={viewBox.cx} y={(viewBox.cy ?? 0) - 6} className="fill-fg text-2xl font-semibold tabular-nums">
            {total.toLocaleString()}
          </tspan>
          <tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 16} className="fill-fg-muted text-ui-xs">
            visitors
          </tspan>
        </text>
      ) : null
    }
  />
</Pie>

Pie legends and tooltips

A pie's series are its rows, not its fields, so tell the legend and the tooltip which field names a slice: nameKey, the same one the Pie takes. Each slice's name is then looked up in the config for its label.

<ChartTooltip content={<ChartTooltipContent nameKey="source" hideLabel />} />
<ChartLegend content={<ChartLegendContent nameKey="source" />} />

A period filter that drives the charts

Keep the period in one piece of state: a SegmentedControl sets it from presets, a DateRangePicker sets a custom range, and every chart reads the rows inside it. A preset fills the picker; picking a range clears the preset.

'use client'

import { useMemo, useState } from 'react'
import { SegmentedControl, SegmentedControlItem } from '@youj/react'
import { DateRangePicker, type DateRange } from '@youj/react/date'

const DAY = 86_400_000
const PRESETS = { '7d': 7, '30d': 30, '90d': 90 } as const

export function Overview({ rows, latest }: { rows: { date: Date; value: number }[]; latest: Date }) {
  const [preset, setPreset] = useState<keyof typeof PRESETS | ''>('30d')
  const [range, setRange] = useState<DateRange>({ from: new Date(latest.getTime() - 29 * DAY), to: latest })
  const shown = useMemo(() => rows.filter((r) => r.date >= range.from! && r.date <= range.to!), [rows, range])
  return (
    <>
      <div className="flex flex-wrap items-end gap-3">
        <SegmentedControl
          aria-label="Period"
          value={preset}
          onValueChange={(value) => {
            if (!(value in PRESETS)) return
            const key = value as keyof typeof PRESETS
            setPreset(key)
            setRange({ from: new Date(latest.getTime() - (PRESETS[key] - 1) * DAY), to: latest })
          }}
        >
          <SegmentedControlItem value="7d">7 days</SegmentedControlItem>
          <SegmentedControlItem value="30d">30 days</SegmentedControlItem>
          <SegmentedControlItem value="90d">90 days</SegmentedControlItem>
        </SegmentedControl>
        <DateRangePicker
          value={range}
          maxDate={latest}
          onValueChange={(next) => {
            if (!next?.from || !next.to) return
            setPreset('')
            setRange(next)
          }}
        />
      </div>
      <TrendChart rows={shown} />
      <BreakdownChart rows={shown} />
    </>
  )
}

Anchor the presets to the newest row (latest), not to the clock: the page renders the same on the server and in the browser, and a prerender never reads the time.

Loading

While the rows load, show a Skeleton the chart's size in its place, so nothing moves when the chart arrives:

import { Skeleton } from '@youj/react'

{loading ? <Skeleton className="aspect-video w-full" /> : <ChartContainer config={config}>…</ChartContainer>}

Heatmaps: a value per cell

There's no general heatmap. For a value in each cell of a grid, pick the one that fits the grid:

  • A value by hour (or any column) for a few kinds: Density Lanes, one lane of dots per kind, swelling where it's busy.
  • A value by day, as a streak or a habit: Calendar Heatmap, a day grid by week.
  • How well each row is covered by each column: Coverage Matrix, with the gaps hatched.

Reference

Propsconfig, children, title… (29)

Props

ChartContainer

ChartContainer props
PropType
configRequiredChartConfig–
childrenRequiredstring | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode>

One Recharts chart (LineChart, BarChart, PieChart…).

–
titlestring

The figure's accessible name, e.g. "Revenue by month".

–
summaryReactNode

One or two sentences on what the chart shows (the trend, the extremes), read after the name.

–
initialDimension{ width: number; height: number }

Size used before the container is measured (and on the server).

default INITIAL_DIMENSION

INITIAL_DIMENSION
Other props pass through: extends ComponentProps<'div'>.

ChartTooltipContent

ChartTooltipContent props
PropType
activeboolean–
payloadreadonly TooltipPayloadItem[]–
labelReactNode–
hideLabelboolean

default false

false
hideIndicatorboolean

default false

false
indicator'line' | 'dot' | 'dashed'

default 'dot'

'dot'
nameKeystring

The payload field whose value names each row in the config.

–
labelKeystring

The payload field whose value is the heading.

–
labelFormatter(label: ReactNode, payload: readonly TooltipPayloadItem[]) => ReactNode–
labelClassNamestring–
valueFormatter(value: number | string, name: string, item: TooltipPayloadItem) => ReactNode

Formats each value, e.g. as currency. Default: toLocaleString.

–
colorstring–
Other props pass through: extends ComponentProps<'div'>.

ChartLegendContent

ChartLegendContent props
PropType
payloadreadonly RechartsPrimitive.LegendPayload[]–
verticalAlign'top' | 'middle' | 'bottom'

default 'bottom'

'bottom'
hideIconboolean

default false

false
nameKeystring–
Other props pass through: extends ComponentProps<'div'>.

ChartDataTable

ChartDataTable props
PropType
dataRequiredreadonly Record<string, unknown>[]–
configRequiredChartConfig–
labelKeyRequiredstring

The field that names each row (the x axis, or the slice name).

–
labelHeadingReactNode

Heading for the label column. Default: the config's label for labelKey, else the key.

–
keysstring[]

Columns to show, in order. Default: every config key present in the data.

–
captionRequiredReactNode

Describes the table, e.g. "Revenue by month, in USD".

–
mode'hidden' | 'toggle'

hidden: a visually hidden table for assistive tech. toggle: a "View data" button that reveals it.

default 'hidden'

'hidden'
valueFormatter(value: number, key: string) => ReactNode–
Other props pass through: extends ComponentProps<'div'>.

ChartTooltip and ChartLegend take Recharts' Tooltip and Legend props. useChartMotion() returns draw(i), grow(i) and sweep(i) for series, tooltip for the tooltip, and reduced.

MotionLines and areas enter, bars enter, pies and radials enter… · reduced motion

Motion

Motion tokens
MomentTokens
Lines and areas entermotion.draw(): the stroke draws in and the area wipes in
duration.deliberateease.enter

560ms · cubic-bezier(0.16, 1, 0.3, 1)

560mscubic-bezier(0.16, 1, 0.3, 1)
Bars entermotion.grow(): from the baseline
duration.slowease.enter

340ms · cubic-bezier(0.16, 1, 0.3, 1)

340mscubic-bezier(0.16, 1, 0.3, 1)
Pies and radials entermotion.sweep(): round from the start angle
duration.deliberateease.enter

560ms · cubic-bezier(0.16, 1, 0.3, 1)

560mscubic-bezier(0.16, 1, 0.3, 1)
Later serieseach one a stagger step behind the one before
stagger.loose

72ms step

72ms step
Data changeevery mark morphs from where it is, with its entrance timing
no token

Tooltipglides between points
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
View datathe table opens with Collapse: height and opacity
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

Marks render in place, a data change jumps to the new values and the tooltip moves without gliding. The data table opens at once.

Recharts animates the marks itself. useChartMotion() replaces its 1.5s default ease with the token durations and curves, scaled by the Speed control, so a chart moves on the same clock as the rest of the page. The values it returns are plain numbers and strings, stable across renders, because Recharts restarts an animation whenever they change.

Reduced motion

Every mark is drawn in its final place, updates replace the old values at once, and the tooltip jumps to the pointer. The chart carries exactly the same information: only the transitions go.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • ChartContainer is a figure (role="figure") named by title. summary is read after the name, so say what the chart shows: the trend, the peak, the outlier.
  • The plot is one Tab stop. The arrow keys walk the points and open the tooltip on each, and the ring shows on keyboard focus only.
  • ChartDataTable gives the same numbers as a real table with a caption, column headers and row headers. Keep it hidden for screen readers, or put it behind View data, which is a disclosure button with aria-expanded.
  • Color is never the only key: the legend and the tooltip name every series, and the table holds every value.