Skip to content

Chart

The Recharts container with Canon's chart colors, tooltip and legend.

Status
Stable
Category
Data display
Adoption
Not used yet
import { ChartContainer } from "@oration/canon/components/chart";
packages/canon/src/components/chart.tsx

Invoices processed

Auto-match cleared 86% of invoices last week, up from 74% in early July.

import {  type ChartConfig,  ChartContainer,  ChartLegend,  ChartLegendContent,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { useReducedMotion } from "motion/react";import * as React from "react";import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";export function Hero() {    const weeks = [        { week: "Jul 6", matched: 412, review: 148 },        { week: "Jul 13", matched: 430, review: 141 },        { week: "Jul 20", matched: 455, review: 139 },        { week: "Jul 27", matched: 441, review: 132 },        { week: "Aug 3", matched: 468, review: 128 },        { week: "Aug 10", matched: 490, review: 120 },        { week: "Aug 17", matched: 502, review: 117 },        { week: "Aug 24", matched: 515, review: 109 },        { week: "Aug 31", matched: 498, review: 112 },        { week: "Sep 7", matched: 534, review: 101 },        { week: "Sep 14", matched: 551, review: 96 },        { week: "Sep 21", matched: 566, review: 92 },    ];    const chartConfig = {        matched: { label: "Auto-matched", color: "var(--chart-1)" },        review: { label: "Sent to review", color: "var(--chart-2)" },    } satisfies ChartConfig;    const [range, setRange] = React.useState<"4w" | "12w">("12w");    const reduce = useReducedMotion();    const animation = {        isAnimationActive: !reduce,        animationDuration: 250,        animationEasing: "ease-out" as const,    };    const data = range === "4w" ? weeks.slice(-4) : weeks;    return (        <section className="w-full max-w-3xl rounded-xl bg-card p-4 shadow-border">            <div className="flex items-start justify-between gap-4">                <div className="min-w-0">                    <h3 className="text-sm font-medium">Invoices processed</h3>                    <p className="mt-0.5 text-pretty text-13 text-muted-foreground">                        Auto-match cleared 86% of invoices last week, up from                        74% in early July.                    </p>                </div>                <SegmentedControl                    label="Range"                    value={range}                    onValueChange={setRange}                    options={[                        { value: "4w", label: "4 weeks" },                        { value: "12w", label: "12 weeks" },                    ]}                />            </div>            <ChartContainer                config={chartConfig}                className="mt-4 aspect-auto h-56 w-full"            >                <LineChart data={data} margin={{ left: 0, right: 8, top: 8 }}>                    <CartesianGrid vertical={false} />                    <XAxis                        dataKey="week"                        tickLine={false}                        axisLine={false}                        tickMargin={8}                        minTickGap={24}                    />                    <YAxis tickLine={false} axisLine={false} width={40} />                    <ChartTooltip                        content={                            <ChartTooltipContent                                labelFormatter={(label) => `Week of ${label}`}                            />                        }                    />                    <ChartLegend content={<ChartLegendContent />} />                    <Line                        dataKey="matched"                        type="monotone"                        stroke="var(--color-matched)"                        strokeWidth={2}                        dot={false}                        {...animation}                    />                    <Line                        dataKey="review"                        type="monotone"                        stroke="var(--color-review)"                        strokeWidth={2}                        dot={false}                        {...animation}                    />                </LineChart>            </ChartContainer>        </section>    );}

Usage#

Chart is the wrapper every Recharts chart in Oration sits in. ChartContainer takes a config that names each series and gives it a color, writes those colors as --color-<key> variables, makes the chart responsive and restyles Recharts' grid, cursor and ticks with Canon tokens; ChartTooltipContent and ChartLegendContent draw the tooltip and legend from the same config. The common mistake is reaching for brand or tag colors: series take the chart set in order, starting with Chart Ink, and nothing else.

When to use

  • For a trend over time that people read for its shape: invoices processed per week, days to pay, payments sent per day.
  • To compare a handful of categories by magnitude, such as spend by supplier, as a vertical or horizontal bar chart.
  • For part-to-whole over time, as a stacked bar or stacked area: payments by method per payment run.
  • In report panels and settings pages where the chart sits under a title and a one-sentence takeaway.
  • When people need a tooltip with exact values on hover or with the arrow keys.

When not to use

  • For a sparkline in a table cell, a stat or a list row. It has no axes and needs no container. Use Mini chart
  • For a single ratio, such as 86% auto-matched. Use Radial chart
  • For one magnitude against a limit, such as credits used this period. Meters use ink steps, not the chart set. Use Meter
  • For a status history, one tick per period. Use Health bar
  • For two to five headline numbers. Don't chart a number that reads fine as text. Use Inline stat strip
  • When people need to read, sort or export exact values. Use Table

The chart set

Series take --chart-1 to --chart-5 in order: Chart Ink, Chart Teal, Chart Amber, Chart Magenta, then Chart Soft Ink. Most charts lead with Chart Ink, and a single-series chart is always Chart Ink. The chart set is used inside the chart container only.

The Option Hue Rule

The ten tag hues belong to option values and identity tints. They never color a chart series, even when the series is a stage or tier that has a tag color elsewhere.

The Quiet Indigo Rule

Indigo never fills data. A series is not indigo because it is the important one; lead with Chart Ink instead.

No hero-metric cards

Don't build grids of big numbers with tiny labels and trend chips. Show metrics as a stat strip, rows, a table or a real chart with axes.

Anatomy#

  1. Container. ChartContainer: a data-chart div that holds the config, the generated color variables and Recharts' ResponsiveContainer. Text is 12px.
  2. Legend. ChartLegendContent: an 8px swatch and the config label for each series, centered, 16px apart, 12px from the plot.
  3. Value axis. Ticks in Slate Meta. Product charts drop the tick and axis lines (tickLine={false} axisLine={false}) and give the axis a fixed width.
  4. Grid. Horizontal rules only (vertical={false}), drawn in Hairline at 50% by the container's styles.
  5. Series. Lines, bars or areas filled with var(--color-<key>). Lines are 2px with no dots; bars get 3px corners on their outer end.
  6. Tooltip. ChartTooltipContent: the category label, then one row per series with its indicator, label and value in tabular figures.
  7. Category axis. Dates or names along the base, 8px from the plot, thinned with minTickGap so labels never collide.

Examples#

Series colors

The five chart colors in series order, stacked here by invoice intake channel. Lead with Chart Ink and add colors in this order.

  1. Chart Inkchart-1
  2. Chart Tealchart-2
  3. Chart Amberchart-3
  4. Chart Magentachart-4
  5. Chart Soft Inkchart-5
import {  type ChartConfig,  ChartContainer,  ChartLegend,  ChartLegendContent,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { useReducedMotion } from "motion/react";import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";export function SeriesColors() {    const swatches = [        { token: "chart-1", name: "Chart Ink", className: "bg-chart-1" },        { token: "chart-2", name: "Chart Teal", className: "bg-chart-2" },        { token: "chart-3", name: "Chart Amber", className: "bg-chart-3" },        { token: "chart-4", name: "Chart Magenta", className: "bg-chart-4" },        { token: "chart-5", name: "Chart Soft Ink", className: "bg-chart-5" },    ];    const months = [        {            month: "Jun",            email: 1240,            portal: 820,            edi: 410,            api: 180,            paper: 260,        },        {            month: "Jul",            email: 1310,            portal: 905,            edi: 430,            api: 240,            paper: 230,        },        {            month: "Aug",            email: 1295,            portal: 980,            edi: 455,            api: 310,            paper: 190,        },        {            month: "Sep",            email: 1180,            portal: 1040,            edi: 470,            api: 365,            paper: 150,        },    ];    const chartConfig = {        email: { label: "Email", color: "var(--chart-1)" },        portal: { label: "Supplier portal", color: "var(--chart-2)" },        edi: { label: "EDI", color: "var(--chart-3)" },        api: { label: "API", color: "var(--chart-4)" },        paper: { label: "Paper scan", color: "var(--chart-5)" },    } satisfies ChartConfig;    const reduce = useReducedMotion();    return (        <div className="grid w-full max-w-3xl gap-6">            <ol className="grid grid-cols-2 gap-x-4 gap-y-2 text-13 sm:grid-cols-5">                {swatches.map((swatch) => (                    <li key={swatch.token} className="flex items-center gap-2">                        <span                            aria-hidden="true"                            className={`size-3 shrink-0 rounded-[3px] ${swatch.className}`}                        />                        <span className="min-w-0">                            <span className="block font-medium">                                {swatch.name}                            </span>                            <span className="block font-mono text-xs text-muted-foreground">                                {swatch.token}                            </span>                        </span>                    </li>                ))}            </ol>            <ChartContainer                config={chartConfig}                className="aspect-auto h-60 w-full"            >                <BarChart data={months} margin={{ left: 0, right: 4, top: 4 }}>                    <CartesianGrid vertical={false} />                    <XAxis                        dataKey="month"                        tickLine={false}                        axisLine={false}                        tickMargin={8}                    />                    <YAxis tickLine={false} axisLine={false} width={40} />                    <ChartTooltip content={<ChartTooltipContent />} />                    <ChartLegend content={<ChartLegendContent />} />                    {(["email", "portal", "edi", "api", "paper"] as const).map(                        (key, index, keys) => (                            <Bar                                key={key}                                dataKey={key}                                stackId="intake"                                fill={`var(--color-${key})`}                                maxBarSize={48}                                radius={                                    index === keys.length - 1 ? [3, 3, 0, 0] : 0                                }                                isAnimationActive={!reduce}                            />                        ),                    )}                </BarChart>            </ChartContainer>        </div>    );}

Line

A single series is always Chart Ink. A dashed reference line in Slate Meta marks the terms the trend is measured against.

import {  type ChartConfig,  ChartContainer,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { useReducedMotion } from "motion/react";import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts";export function LineExample() {    const weeks = [        { week: "Jul 6", days: 34 },        { week: "Jul 13", days: 33 },        { week: "Jul 20", days: 33 },        { week: "Jul 27", days: 31 },        { week: "Aug 3", days: 32 },        { week: "Aug 10", days: 30 },        { week: "Aug 17", days: 29 },        { week: "Aug 24", days: 28 },        { week: "Aug 31", days: 29 },        { week: "Sep 7", days: 27 },        { week: "Sep 14", days: 26 },        { week: "Sep 21", days: 26 },    ];    const chartConfig = {        days: { label: "Days to pay", color: "var(--chart-1)" },    } satisfies ChartConfig;    const reduce = useReducedMotion();    return (        <ChartContainer            config={chartConfig}            className="aspect-auto h-56 w-full"        >            <LineChart data={weeks} margin={{ left: 0, right: 8, top: 8 }}>                <CartesianGrid vertical={false} />                <XAxis                    dataKey="week"                    tickLine={false}                    axisLine={false}                    tickMargin={8}                    minTickGap={24}                />                <YAxis                    tickLine={false}                    axisLine={false}                    width={40}                    domain={[20, 40]}                    unit="d"                />                <ReferenceLine                    y={30}                    stroke="var(--muted-foreground)"                    strokeDasharray="4 4"                    label={{                        value: "Net 30",                        position: "insideTopRight",                        fontSize: 11,                        fill: "var(--muted-foreground)",                    }}                />                <ChartTooltip                    content={                        <ChartTooltipContent                            indicator="line"                            labelFormatter={(label) => `Week of ${label}`}                        />                    }                />                <Line                    dataKey="days"                    type="monotone"                    stroke="var(--color-days)"                    strokeWidth={2}                    dot={false}                    isAnimationActive={!reduce}                    animationDuration={250}                    animationEasing="ease-out"                />            </LineChart>        </ChartContainer>    );}

Bar

Counts per day, with 3px top corners, a capped bar width and the Well Gray cursor band on hover.

import {  type ChartConfig,  ChartContainer,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { useReducedMotion } from "motion/react";import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";export function BarExample() {    const days = [        { day: "Sep 14", invoices: 142 },        { day: "Sep 15", invoices: 156 },        { day: "Sep 16", invoices: 149 },        { day: "Sep 17", invoices: 161 },        { day: "Sep 18", invoices: 133 },        { day: "Sep 19", invoices: 18 },        { day: "Sep 20", invoices: 9 },        { day: "Sep 21", invoices: 151 },        { day: "Sep 22", invoices: 167 },        { day: "Sep 23", invoices: 158 },        { day: "Sep 24", invoices: 172 },        { day: "Sep 25", invoices: 140 },        { day: "Sep 26", invoices: 21 },        { day: "Sep 27", invoices: 12 },    ];    const chartConfig = {        invoices: { label: "Invoices received", color: "var(--chart-1)" },    } satisfies ChartConfig;    const reduce = useReducedMotion();    return (        <ChartContainer            config={chartConfig}            className="aspect-auto h-56 w-full"        >            <BarChart data={days} margin={{ left: 0, right: 4, top: 4 }}>                <CartesianGrid vertical={false} />                <XAxis                    dataKey="day"                    tickLine={false}                    axisLine={false}                    tickMargin={8}                    minTickGap={16}                />                <YAxis tickLine={false} axisLine={false} width={40} />                <ChartTooltip                    cursor={{ fill: "var(--muted)", opacity: 0.5 }}                    content={<ChartTooltipContent />}                />                <Bar                    dataKey="invoices"                    fill="var(--color-invoices)"                    radius={[3, 3, 0, 0]}                    maxBarSize={28}                    isAnimationActive={!reduce}                    animationDuration={250}                    animationEasing="ease-out"                />            </BarChart>        </ChartContainer>    );}

Horizontal bar

For ranked categories with long names, set layout="vertical" and size the height by row count. formatter prints the value as currency.

import {  type ChartConfig,  ChartContainer,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { useReducedMotion } from "motion/react";import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";export function HorizontalBar() {    const suppliers = [        { supplier: "Northwind Freight", spend: 184200 },        { supplier: "Halcyon Health", spend: 126500 },        { supplier: "Orchard Street Market", spend: 98400 },        { supplier: "Meridian Parts Co.", spend: 76900 },        { supplier: "Bluewater Logistics", spend: 54300 },        { supplier: "Lattice Office Supply", spend: 31800 },    ];    const chartConfig = {        spend: { label: "Spend", color: "var(--chart-1)" },    } satisfies ChartConfig;    const usd = new Intl.NumberFormat("en-US", {        style: "currency",        currency: "USD",        notation: "compact",        maximumFractionDigits: 1,    });    const reduce = useReducedMotion();    return (        <ChartContainer            config={chartConfig}            className="aspect-auto w-full"            style={{ height: suppliers.length * 36 + 32 }}        >            <BarChart                data={suppliers}                layout="vertical"                margin={{ left: 0, right: 16 }}                barCategoryGap="28%"            >                <CartesianGrid horizontal={false} />                <XAxis                    type="number"                    tickLine={false}                    axisLine={false}                    tickFormatter={(value: number) => usd.format(value)}                />                <YAxis                    type="category"                    dataKey="supplier"                    tickLine={false}                    axisLine={false}                    width={150}                    tick={{ fontSize: 12 }}                />                <ChartTooltip                    cursor={{ fill: "var(--muted)", opacity: 0.5 }}                    content={                        <ChartTooltipContent                            formatter={(value) => (                                <span className="flex flex-1 items-center justify-between gap-4 leading-none">                                    <span className="text-muted-foreground">                                        Spend this quarter                                    </span>                                    <span className="font-medium text-foreground tabular-nums">                                        {usd.format(Number(value))}                                    </span>                                </span>                            )}                        />                    }                />                <Bar                    dataKey="spend"                    fill="var(--color-spend)"                    radius={[0, 3, 3, 0]}                    maxBarSize={18}                    isAnimationActive={!reduce}                    animationDuration={250}                    animationEasing="ease-out"                />            </BarChart>        </ChartContainer>    );}

Area

Stacked areas with a 1.5px stroke and a light fill, 18% for the lead series and 30% for the one above it.

import {  type ChartConfig,  ChartContainer,  ChartLegend,  ChartLegendContent,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { useReducedMotion } from "motion/react";import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";export function AreaExample() {    const weeks = [        { week: "Jul 6", onTime: 182, late: 41 },        { week: "Jul 13", onTime: 190, late: 38 },        { week: "Jul 20", onTime: 201, late: 36 },        { week: "Jul 27", onTime: 196, late: 35 },        { week: "Aug 3", onTime: 210, late: 31 },        { week: "Aug 10", onTime: 214, late: 30 },        { week: "Aug 17", onTime: 220, late: 27 },        { week: "Aug 24", onTime: 226, late: 25 },        { week: "Aug 31", onTime: 219, late: 26 },        { week: "Sep 7", onTime: 231, late: 22 },        { week: "Sep 14", onTime: 238, late: 20 },        { week: "Sep 21", onTime: 244, late: 18 },    ];    const chartConfig = {        onTime: { label: "Paid on time", color: "var(--chart-1)" },        late: { label: "Paid late", color: "var(--chart-2)" },    } satisfies ChartConfig;    const reduce = useReducedMotion();    const animation = {        isAnimationActive: !reduce,        animationDuration: 250,        animationEasing: "ease-out" as const,    };    return (        <ChartContainer            config={chartConfig}            className="aspect-auto h-56 w-full"        >            <AreaChart data={weeks} margin={{ left: 0, right: 8, top: 4 }}>                <CartesianGrid vertical={false} />                <XAxis                    dataKey="week"                    tickLine={false}                    axisLine={false}                    tickMargin={8}                    minTickGap={24}                />                <YAxis tickLine={false} axisLine={false} width={40} />                <ChartTooltip                    content={                        <ChartTooltipContent                            labelFormatter={(label) => `Week of ${label}`}                        />                    }                />                <ChartLegend content={<ChartLegendContent />} />                <Area                    dataKey="onTime"                    stackId="payments"                    type="monotone"                    stroke="var(--color-onTime)"                    fill="var(--color-onTime)"                    fillOpacity={0.18}                    strokeWidth={1.5}                    {...animation}                />                <Area                    dataKey="late"                    stackId="payments"                    type="monotone"                    stroke="var(--color-late)"                    fill="var(--color-late)"                    fillOpacity={0.3}                    strokeWidth={1.5}                    {...animation}                />            </AreaChart>        </ChartContainer>    );}

Stacked bar

Give every bar the same stackId and round only the top series. The legend and the stack keep the config's order.

import {  type ChartConfig,  ChartContainer,  ChartLegend,  ChartLegendContent,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { useReducedMotion } from "motion/react";import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";export function StackedBar() {    const runs = [        { run: "Aug 20", ach: 198, card: 52, check: 40, wire: 8 },        { run: "Aug 27", ach: 205, card: 58, check: 36, wire: 11 },        { run: "Sep 3", ach: 221, card: 61, check: 33, wire: 9 },        { run: "Sep 10", ach: 214, card: 60, check: 31, wire: 12 },        { run: "Sep 17", ach: 230, card: 66, check: 28, wire: 10 },        { run: "Sep 24", ach: 236, card: 71, check: 24, wire: 13 },    ];    const chartConfig = {        ach: { label: "ACH", color: "var(--chart-1)" },        card: { label: "Virtual card", color: "var(--chart-2)" },        check: { label: "Check", color: "var(--chart-3)" },        wire: { label: "Wire", color: "var(--chart-4)" },    } satisfies ChartConfig;    const reduce = useReducedMotion();    const animation = {        isAnimationActive: !reduce,        animationDuration: 250,        animationEasing: "ease-out" as const,    };    return (        <ChartContainer            config={chartConfig}            className="aspect-auto h-60 w-full"        >            <BarChart data={runs} margin={{ left: 0, right: 4, top: 4 }}>                <CartesianGrid vertical={false} />                <XAxis                    dataKey="run"                    tickLine={false}                    axisLine={false}                    tickMargin={8}                />                <YAxis tickLine={false} axisLine={false} width={40} />                <ChartTooltip                    cursor={{ fill: "var(--muted)", opacity: 0.5 }}                    content={                        <ChartTooltipContent                            labelFormatter={(label) => `Payment run, ${label}`}                        />                    }                />                <ChartLegend content={<ChartLegendContent />} />                <Bar                    dataKey="ach"                    stackId="method"                    fill="var(--color-ach)"                    maxBarSize={40}                    {...animation}                />                <Bar                    dataKey="card"                    stackId="method"                    fill="var(--color-card)"                    maxBarSize={40}                    {...animation}                />                <Bar                    dataKey="check"                    stackId="method"                    fill="var(--color-check)"                    maxBarSize={40}                    {...animation}                />                <Bar                    dataKey="wire"                    stackId="method"                    fill="var(--color-wire)"                    radius={[3, 3, 0, 0]}                    maxBarSize={40}                    {...animation}                />            </BarChart>        </ChartContainer>    );}

Tooltip indicators

indicator switches the swatch between a dot, a bar and a dashed rule. defaultIndex opens the tooltip on a category without hovering.

import {  type ChartConfig,  ChartContainer,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { SegmentedControl } from "@oration/canon/components/segmented-control";import * as React from "react";import { Bar, BarChart, CartesianGrid, Line, XAxis, YAxis } from "recharts";export function TooltipIndicators() {    const weeks = [        { week: "Aug 17", matched: 502, review: 117 },        { week: "Aug 24", matched: 515, review: 109 },        { week: "Aug 31", matched: 498, review: 112 },        { week: "Sep 7", matched: 534, review: 101 },        { week: "Sep 14", matched: 551, review: 96 },        { week: "Sep 21", matched: 566, review: 92 },    ];    const chartConfig = {        matched: { label: "Auto-matched", color: "var(--chart-1)" },        review: { label: "Sent to review", color: "var(--chart-2)" },    } satisfies ChartConfig;    const [indicator, setIndicator] = React.useState<"dot" | "line" | "dashed">(        "dot",    );    return (        <div className="grid w-full gap-4">            <SegmentedControl                label="Tooltip indicator"                value={indicator}                onValueChange={setIndicator}                options={[                    { value: "dot", label: "Dot" },                    { value: "line", label: "Line" },                    { value: "dashed", label: "Dashed" },                ]}                className="justify-self-start"            />            <ChartContainer                config={chartConfig}                className="aspect-auto h-56 w-full"            >                <BarChart data={weeks} margin={{ left: 0, right: 4, top: 4 }}>                    <CartesianGrid vertical={false} />                    <XAxis                        dataKey="week"                        tickLine={false}                        axisLine={false}                        tickMargin={8}                    />                    <YAxis tickLine={false} axisLine={false} width={40} />                    <ChartTooltip                        defaultIndex={3}                        cursor={{ fill: "var(--muted)", opacity: 0.5 }}                        content={<ChartTooltipContent indicator={indicator} />}                    />                    <Bar                        dataKey="matched"                        fill="var(--color-matched)"                        radius={[3, 3, 0, 0]}                        maxBarSize={24}                        isAnimationActive={false}                    />                    <Bar                        dataKey="review"                        fill="var(--color-review)"                        radius={[3, 3, 0, 0]}                        maxBarSize={24}                        isAnimationActive={false}                    />                </BarChart>            </ChartContainer>        </div>    );}

States#

Loading payments sent
import {  type ChartConfig,  ChartContainer,  ChartTooltip,  ChartTooltipContent,} from "@oration/canon/components/chart";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Skeleton } from "@oration/canon/components/skeleton";import * as React from "react";import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";export function ChartStates() {    const [state, setState] = React.useState<"loading" | "empty" | "ready">(        "loading",    );    React.useEffect(() => {        if (state !== "loading") return;        const id = window.setTimeout(() => setState("ready"), 1200);        return () => window.clearTimeout(id);    }, [state]);    const days = [        { day: "Sep 21", payments: 42 },        { day: "Sep 22", payments: 51 },        { day: "Sep 23", payments: 38 },        { day: "Sep 24", payments: 64 },        { day: "Sep 25", payments: 47 },    ];    const chartConfig = {        payments: { label: "Payments sent", color: "var(--chart-1)" },    } satisfies ChartConfig;    return (        <div className="grid w-full gap-4">            <SegmentedControl                label="Chart state"                value={state}                onValueChange={setState}                options={[                    { value: "loading", label: "Loading" },                    { value: "empty", label: "Empty" },                    { value: "ready", label: "Ready" },                ]}                className="justify-self-start"            />            <div className="h-52 w-full">                {state === "loading" ? (                    <div                        className="flex h-full flex-col gap-3"                        aria-busy="true"                    >                        <span className="sr-only">Loading payments sent</span>                        <Skeleton className="h-full w-full rounded-[10px]" />                        <div className="flex justify-center gap-4">                            <Skeleton className="h-3 w-24" />                        </div>                    </div>                ) : state === "empty" ? (                    <div className="flex h-full flex-col items-center justify-center gap-1 rounded-[10px] bg-muted/70 px-6 text-center">                        <p className="text-sm font-medium">                            No payments sent this week                        </p>                        <p className="max-w-xs text-pretty text-13 text-muted-foreground">                            The next payment run is scheduled for Thursday,                            October 1.                        </p>                    </div>                ) : (                    <ChartContainer                        config={chartConfig}                        className="aspect-auto h-full w-full"                    >                        <BarChart                            data={days}                            margin={{ left: 0, right: 4, top: 4 }}                        >                            <CartesianGrid vertical={false} />                            <XAxis                                dataKey="day"                                tickLine={false}                                axisLine={false}                                tickMargin={8}                            />                            <YAxis                                tickLine={false}                                axisLine={false}                                width={32}                            />                            <ChartTooltip content={<ChartTooltipContent />} />                            <Bar                                dataKey="payments"                                fill="var(--color-payments)"                                radius={[3, 3, 0, 0]}                                maxBarSize={32}                                animationDuration={250}                                animationEasing="ease-out"                            />                        </BarChart>                    </ChartContainer>                )}            </div>        </div>    );}
States
StateTreatment
RestSeries, grid, axes and legend. No tooltip.
HoverThe tooltip follows the nearest category. Line and area charts draw a Hairline cursor rule; bar charts fill the category band with Well Gray (product charts pass opacity: 0.5). The hovered point on a line shows its active dot.
Keyboard focusRecharts makes the chart a tab stop. Arrow keys move the tooltip between categories. The container hides the focus outline, so focus is not visible.
EnteringSeries animate in on mount. Recharts defaults to 1500ms for lines and areas and 400ms for bars; product charts use 250ms ease-out and turn it off for reduced motion.
LoadingNot built in. Draw a Skeleton at the chart's height so the panel doesn't jump when data arrives.
EmptyNot built in. Replace the chart with a sentence that says why there's nothing and what happens next; don't draw empty axes.

Behavior#

  • Each config key becomes a CSS variable on the container: { ach: { color: "var(--chart-1)" } } gives you var(--color-ach) for fill and stroke. Config keys must match your dataKeys so the tooltip and legend can find their labels.
  • Use color: "var(--chart-n)" so the series follows the theme. A theme: { light, dark } map writes raw values per theme, keyed on a .dark ancestor.
  • The chart fills its container's width and needs a height. The default is aspect-video; product charts use aspect-auto with h-56 or an explicit style.height for horizontal bars sized by row count.
  • initialDimension (320 by 200) is the size used before the container is measured, including on the server.
  • The tooltip label comes from labelKey, then the category value (mapped through config if it matches a key). labelFormatter rewrites it, for example to Week of Sep 21.
  • Values print with toLocaleString(). For currency or units pass formatter, which replaces the whole row, indicator included.
  • With one series and a line or dashed indicator, the label nests beside the value instead of sitting above it.
  • ChartTooltip and ChartLegend are Recharts' Tooltip and Legend unchanged, so cursor, defaultIndex, verticalAlign and the rest work as documented by Recharts.

Do and don't#

Do. Color series from the chart set in order, starting with Chart Ink.
Don't. Color series with indigo, red or tag hues. Red reads as an error and indigo as selection.
Do. Keep the grid to horizontal hairlines and drop tick and axis lines.
Don't. Leave Recharts' defaults: a dashed grid in both directions, axis lines, tick marks, dots and a heavy stroke.

Content#

  • Put a title and a one-sentence takeaway above the chart that says what changed: Auto-match cleared 86% of invoices last week, up from 74% in early July.
  • Series labels in config are nouns in sentence case: Auto-matched, Sent to review, Virtual card.
  • Format axis ticks compactly ($184K, 30d) and tooltip values in full ($184,200).
  • Name time categories the way people say them: Sep 21, and Week of Sep 21 in the tooltip.
  • Label reference lines with what they mean: Net 30, Target.
  • Don't add a legend for a single series; the title already names it.

Accessibility#

  • Recharts' accessibility layer is on by default: the chart surface is role="application" with tabIndex={0}, and arrow keys move the tooltip. Pass accessibilityLayer explicitly if you want it to be obvious in code.
  • Give the chart a written takeaway beside it. The SVG has no accessible name unless you pass title and desc to the chart.
  • Never rely on color alone. The legend and tooltip name each series, and stacked charts keep the same order in the legend as in the stack.
  • The set has five colors and two of them are inks, which differ mainly in lightness. Past three or four series, group the rest into Other rather than asking people to tell close colors apart.
  • Turn off series animation for reduced motion with isAnimationActive={!useReducedMotion()}. The container does not do it for you.
  • The focus outline on the chart is hidden by the container's styles; keyboard users can tab to it but can't see that they have.
Keyboard interactions
KeysAction
TabFocuses the chart.
→Moves the tooltip to the next category.
←Moves the tooltip to the previous category.

Design tokens#

Design tokens
TokenUsed for
--chart-1 … --chart-5Series colors, in order, through --color-<key>
--muted-foregroundAxis tick text
--borderGrid rules at 50%, the line cursor, reference lines and polar grids
--mutedBar cursor band and radial bar track
--backgroundTooltip surface
--foregroundTooltip values
shadow-xlTooltip shadow
text-xsAll chart text

API reference#

ChartContainer

Provides the config, writes the color variables and wraps the chart in a ResponsiveContainer.

Other props spread onto <div>.

Props of ChartContainer
PropTypeDefaultDescription
configRequiredChartConfigNo defaultOne entry per series key: label, optional icon, and either color or theme.
childrenRequiredResponsiveContainer childrenNo defaultA single Recharts chart: LineChart, BarChart, AreaChart and so on.
idstringNo defaultUsed for data-chart="chart-<id>". Defaults to a useId() value.
initialDimension{ width: number; height: number }{ width: 320, height: 200 }Size used before the container has been measured.
classNamestringNo defaultMerged onto the container. Set aspect-auto and a height, or keep the default aspect-video.

ChartConfig

The type of config.

Props of ChartConfig
PropTypeDefaultDescription
[key]{ label?: ReactNode; icon?: ComponentType } & ({ color?: string } | { theme: { light: string; dark: string } })No defaultKeys match the series dataKey. color and theme are mutually exclusive.

ChartTooltip

Recharts Tooltip, re-exported.

Other props spread onto Recharts Tooltip.

Props of ChartTooltip
PropTypeDefaultDescription
contentReactElementNo defaultPass <ChartTooltipContent />.
cursorboolean | SVGPropstrueThe hover cursor. Product bar charts pass { fill: "var(--muted)", opacity: 0.5 }.
defaultIndexnumberNo defaultShows the tooltip at this category on first render.

ChartTooltipContent

The tooltip body. active, payload and label are injected by ChartTooltip.

Other props spread onto Recharts Tooltip props and <div> props.

Props of ChartTooltipContent
PropTypeDefaultDescription
indicator"dot" | "line" | "dashed""dot"The swatch beside each row.
hideLabelbooleanfalseHides the category label.
hideIndicatorbooleanfalseHides the swatches.
labelFormatter(label, payload) => ReactNodeNo defaultRewrites the label, for example to add Week of.
formatter(value, name, item, index, payload) => ReactNodeNo defaultReplaces each row entirely, including the indicator. Use it for currency and units.
labelKeystringNo defaultConfig key to read the label from.
nameKeystringNo defaultPayload field to look up each row's config entry, when it isn't the dataKey.
colorstringNo defaultOverrides every indicator color.
labelClassNamestringNo defaultClasses for the label.
classNamestringNo defaultMerged onto the tooltip surface.

ChartLegend

Recharts Legend, re-exported.

Other props spread onto Recharts Legend.

Props of ChartLegend
PropTypeDefaultDescription
contentReactElementNo defaultPass <ChartLegendContent />.
verticalAlign"top" | "middle" | "bottom""bottom"Where the legend sits. Passed through to the content, which flips its padding.

ChartLegendContent

The legend body: a swatch and label for each series, from config.

Other props spread onto Recharts legend content props and <div> props.

Props of ChartLegendContent
PropTypeDefaultDescription
hideIconbooleanfalseHides the config icon and shows the swatch instead.
nameKeystringNo defaultConfig key to read each label from.
verticalAlign"top" | "bottom""bottom"Puts 12px padding below (top) or above (bottom).
classNamestringNo defaultMerged onto the legend row.

ChartStyle

Writes the --color-<key> variables for a config. ChartContainer renders it; you rarely need it directly.

Props of ChartStyle
PropTypeDefaultDescription
idRequiredstringNo defaultThe container's data-chart value.
configRequiredChartConfigNo defaultThe config to write.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The tooltip is bg-background with border-border/50 and shadow-xl, not Popover White and the popover shadow every other floating surface uses.

No reduced-motion handling. Recharts animates every series by default. Product charts import a useChartMotion helper from apps/web/src/components/reports/report-parts.tsx (250ms ease-out, off when reduced); it isn't in packages/canon.

[&_.recharts-surface]:outline-hidden hides the focus outline on a chart Recharts makes focusable, so keyboard focus is invisible.

There is no value formatter. formatter replaces the whole row, so reports, analytics and usage charts rebuild it with a hand-rolled TooltipRow (mono value, 10px swatch).

The default aspect-video is overridden by every product use.

The grid, cursor and dot styles match Recharts' default #ccc and #fff strokes. A CartesianGrid with a custom stroke loses the Hairline mapping.

Some product charts skip ahead in the chart set: AI call containment pairs Chart Ink with Chart Soft Ink, and latency pairs it with Chart Amber.

A config entry without a label renders a legend swatch with no text.