Chart
The Recharts container with Canon's chart colors, tooltip and legend.
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
--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 Quiet Indigo Rule
No hero-metric cards
Anatomy#
- Container.
ChartContainer: adata-chartdiv that holds the config, the generated color variables and Recharts'ResponsiveContainer. Text is 12px. - Legend.
ChartLegendContent: an 8px swatch and the configlabelfor each series, centered, 16px apart, 12px from the plot. - Value axis. Ticks in Slate Meta. Product charts drop the tick and axis lines (
tickLine={false} axisLine={false}) and give the axis a fixedwidth. - Grid. Horizontal rules only (
vertical={false}), drawn in Hairline at 50% by the container's styles. - Series. Lines, bars or areas filled with
var(--color-<key>). Lines are 2px with no dots; bars get 3px corners on their outer end. - Tooltip.
ChartTooltipContent: the category label, then one row per series with its indicator, label and value in tabular figures. - Category axis. Dates or names along the base, 8px from the plot, thinned with
minTickGapso 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.
- Chart Inkchart-1
- Chart Tealchart-2
- Chart Amberchart-3
- Chart Magentachart-4
- 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#
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> );}| State | Treatment |
|---|---|
| Rest | Series, grid, axes and legend. No tooltip. |
| Hover | The 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 focus | Recharts makes the chart a tab stop. Arrow keys move the tooltip between categories. The container hides the focus outline, so focus is not visible. |
| Entering | Series 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. |
| Loading | Not built in. Draw a Skeleton at the chart's height so the panel doesn't jump when data arrives. |
| Empty | Not 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
configkey becomes a CSS variable on the container:{ ach: { color: "var(--chart-1)" } }gives youvar(--color-ach)forfillandstroke. Config keys must match yourdataKeys so the tooltip and legend can find their labels. - Use
color: "var(--chart-n)"so the series follows the theme. Atheme: { light, dark }map writes raw values per theme, keyed on a.darkancestor. - The chart fills its container's width and needs a height. The default is
aspect-video; product charts useaspect-autowithh-56or an explicitstyle.heightfor 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 throughconfigif it matches a key).labelFormatterrewrites it, for example to Week of Sep 21. - Values print with
toLocaleString(). For currency or units passformatter, which replaces the whole row, indicator included. - With one series and a
lineordashedindicator, the label nests beside the value instead of sitting above it. ChartTooltipandChartLegendare Recharts'TooltipandLegendunchanged, socursor,defaultIndex,verticalAlignand the rest work as documented by Recharts.
Do and don't#
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
configare 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"withtabIndex={0}, and arrow keys move the tooltip. PassaccessibilityLayerexplicitly 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
titleanddescto 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.
| Keys | Action |
|---|---|
| Tab | Focuses the chart. |
| → | Moves the tooltip to the next category. |
| ← | Moves the tooltip to the previous category. |
Design tokens#
| Token | Used for |
|---|---|
--chart-1 … --chart-5 | Series colors, in order, through --color-<key> |
--muted-foreground | Axis tick text |
--border | Grid rules at 50%, the line cursor, reference lines and polar grids |
--muted | Bar cursor band and radial bar track |
--background | Tooltip surface |
--foreground | Tooltip values |
shadow-xl | Tooltip shadow |
text-xs | All chart text |
API reference#
ChartContainer
Provides the config, writes the color variables and wraps the chart in a ResponsiveContainer.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
configRequired | ChartConfig | No default | One entry per series key: label, optional icon, and either color or theme. |
childrenRequired | ResponsiveContainer children | No default | A single Recharts chart: LineChart, BarChart, AreaChart and so on. |
id | string | No default | Used 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. |
className | string | No default | Merged onto the container. Set aspect-auto and a height, or keep the default aspect-video. |
ChartConfig
The type of config.
| Prop | Type | Default | Description |
|---|---|---|---|
[key] | { label?: ReactNode; icon?: ComponentType } & ({ color?: string } | { theme: { light: string; dark: string } }) | No default | Keys match the series dataKey. color and theme are mutually exclusive. |
ChartTooltip
Recharts Tooltip, re-exported.
Other props spread onto Recharts Tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactElement | No default | Pass <ChartTooltipContent />. |
cursor | boolean | SVGProps | true | The hover cursor. Product bar charts pass { fill: "var(--muted)", opacity: 0.5 }. |
defaultIndex | number | No default | Shows 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.
| Prop | Type | Default | Description |
|---|---|---|---|
indicator | "dot" | "line" | "dashed" | "dot" | The swatch beside each row. |
hideLabel | boolean | false | Hides the category label. |
hideIndicator | boolean | false | Hides the swatches. |
labelFormatter | (label, payload) => ReactNode | No default | Rewrites the label, for example to add Week of. |
formatter | (value, name, item, index, payload) => ReactNode | No default | Replaces each row entirely, including the indicator. Use it for currency and units. |
labelKey | string | No default | Config key to read the label from. |
nameKey | string | No default | Payload field to look up each row's config entry, when it isn't the dataKey. |
color | string | No default | Overrides every indicator color. |
labelClassName | string | No default | Classes for the label. |
className | string | No default | Merged onto the tooltip surface. |
ChartLegend
Recharts Legend, re-exported.
Other props spread onto Recharts Legend.
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactElement | No default | Pass <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.
| Prop | Type | Default | Description |
|---|---|---|---|
hideIcon | boolean | false | Hides the config icon and shows the swatch instead. |
nameKey | string | No default | Config key to read each label from. |
verticalAlign | "top" | "bottom" | "bottom" | Puts 12px padding below (top) or above (bottom). |
className | string | No default | Merged onto the legend row. |
ChartStyle
Writes the --color-<key> variables for a config. ChartContainer renders it; you rarely need it directly.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | The container's data-chart value. |
configRequired | ChartConfig | No default | The 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.