Chart
Themed, accessible charts built on Recharts, with a validated categorical palette, quiet chrome and tooltips that match the system.
"use client"
import { Area, AreaChart } from "recharts"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
const data = [
{ day: "Mon", visitors: 3120 },
{ day: "Tue", visitors: 3480 },
{ day: "Wed", visitors: 3290 },
{ day: "Thu", visitors: 4010 },
{ day: "Fri", visitors: 4620 },
{ day: "Sat", visitors: 3890 },
{ day: "Sun", visitors: 4980 },
]
const config = {
visitors: { label: "Visitors", color: "var(--chart-1)" },
} satisfies ChartConfig
export default function ChartDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-5 shadow-raised">
<div className="flex flex-col gap-1">
<span className="text-body-xs text-muted-foreground">
Visitors this week
</span>
<span className="text-heading-lg">27,390</span>
</div>
<ChartContainer config={config} className="aspect-auto h-20 w-full">
<AreaChart
data={data}
margin={{ top: 4, right: 4, bottom: 4, left: 4 }}
>
<Area
dataKey="visitors"
type="monotone"
stroke="var(--color-visitors)"
strokeWidth={2}
fill="var(--color-visitors)"
fillOpacity={0.1}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/chart.jsonUsage
A chart is Recharts inside a ChartContainer. The container takes a config that names each series and assigns its color; every series color becomes a CSS variable, --color-<key>, scoped to that chart.
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const config = {
visitors: { label: "Visitors", color: "var(--chart-1)" },
} satisfies ChartConfig
<ChartContainer config={config} className="aspect-auto h-64 w-full">
<AreaChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="day" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent indicator="line" />} />
<Area
dataKey="visitors"
stroke="var(--color-visitors)"
strokeWidth={2}
fill="var(--color-visitors)"
fillOpacity={0.1}
/>
</AreaChart>
</ChartContainer>Give the container an explicit height that includes the axis labels — aspect-auto h-64 — so the axes never get clipped into a nested scroll.
Examples
Area
A single series tells one story, so it needs no legend: the title names it. Draw a 2px line with a 10% wash of the same color underneath — a quiet fill, never a solid block.
"use client"
import { TrendingUpIcon } from "lucide-react"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const data = [
{ month: "Oct", requests: 1.82 },
{ month: "Nov", requests: 1.94 },
{ month: "Dec", requests: 2.11 },
{ month: "Jan", requests: 2.03 },
{ month: "Feb", requests: 2.38 },
{ month: "Mar", requests: 2.56 },
{ month: "Apr", requests: 2.49 },
{ month: "May", requests: 2.81 },
{ month: "Jun", requests: 3.07 },
{ month: "Jul", requests: 3.02 },
{ month: "Aug", requests: 3.36 },
{ month: "Sep", requests: 3.78 },
]
const config = {
requests: { label: "Requests (millions)", color: "var(--chart-1)" },
} satisfies ChartConfig
export default function ChartArea() {
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>API requests</CardTitle>
<CardDescription>
Monthly requests, in millions, over the last 12 months.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={config} className="aspect-auto h-64 w-full">
<AreaChart data={data} margin={{ left: 0, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={48}
domain={[0, 4]}
ticks={[0, 1, 2, 3, 4]}
tickFormatter={(value: number) => `${value}M`}
/>
<ChartTooltip
cursor
content={<ChartTooltipContent indicator="line" />}
/>
<Area
dataKey="requests"
type="monotone"
stroke="var(--color-requests)"
strokeWidth={2}
fill="var(--color-requests)"
fillOpacity={0.1}
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--card)" }}
/>
</AreaChart>
</ChartContainer>
</CardContent>
<CardFooter className="gap-2 text-sm">
<TrendingUpIcon className="size-4 text-success-11" aria-hidden="true" />
<span>
<span className="font-medium">Up 108%</span>{" "}
<span className="text-muted-foreground">year over year.</span>
</span>
</CardFooter>
</Card>
)
}Line
With two or more series, a legend is always present. Label the end of each line with its latest value instead of putting a number on every point.
"use client"
import {
CartesianGrid,
LabelList,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const data = [
{ week: "W1", web: 4210, ios: 2680, android: 1940 },
{ week: "W2", web: 4390, ios: 2810, android: 2120 },
{ week: "W3", web: 4280, ios: 3050, android: 2260 },
{ week: "W4", web: 4620, ios: 3210, android: 2410 },
{ week: "W5", web: 4810, ios: 3390, android: 2690 },
{ week: "W6", web: 4760, ios: 3620, android: 2880 },
{ week: "W7", web: 5040, ios: 3890, android: 3170 },
{ week: "W8", web: 5220, ios: 4010, android: 3420 },
]
const config = {
web: { label: "Web", color: "var(--chart-1)" },
ios: { label: "iOS", color: "var(--chart-2)" },
android: { label: "Android", color: "var(--chart-3)" },
} satisfies ChartConfig
const number = new Intl.NumberFormat("en-US")
/** Labels only the last point of each line — selective, never every point. */
function EndLabel(props: {
x?: number | string
y?: number | string
index?: number
value?: number | string
}) {
if (props.index !== data.length - 1) return null
return (
<text
x={Number(props.x) + 8}
y={Number(props.y)}
dy="0.32em"
className="fill-foreground text-xs font-medium"
>
{number.format(Number(props.value))}
</text>
)
}
export default function ChartLine() {
return (
<div className="flex w-full max-w-2xl flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-heading-sm">Weekly active users by platform</span>
<span className="text-body-xs text-muted-foreground">Last 8 weeks</span>
</div>
<ChartContainer config={config} className="aspect-auto h-72 w-full">
<LineChart data={data} margin={{ left: 0, right: 48, top: 12 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="week"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={52}
tickFormatter={(value: number) => number.format(value)}
/>
<ChartTooltip
cursor
content={<ChartTooltipContent indicator="line" />}
/>
<ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
{(["web", "ios", "android"] as const).map((key) => (
<Line
key={key}
dataKey={key}
type="monotone"
stroke={`var(--color-${key})`}
strokeWidth={2}
strokeLinecap="round"
dot={false}
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--background)" }}
>
<LabelList dataKey={key} content={<EndLabel />} />
</Line>
))}
</LineChart>
</ChartContainer>
</div>
)
}Bar
Bars are at most 24px thick, grow from one baseline, and round only their data end (4px). A 2px gap separates bars that touch.
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const data = [
{ month: "Apr", signups: 1840, activated: 1120 },
{ month: "May", signups: 2130, activated: 1390 },
{ month: "Jun", signups: 1960, activated: 1310 },
{ month: "Jul", signups: 2480, activated: 1720 },
{ month: "Aug", signups: 2710, activated: 1960 },
{ month: "Sep", signups: 3020, activated: 2290 },
]
const config = {
signups: { label: "Sign-ups", color: "var(--chart-1)" },
activated: { label: "Activated", color: "var(--chart-2)" },
} satisfies ChartConfig
const number = new Intl.NumberFormat("en-US")
export default function ChartBar() {
return (
<div className="flex w-full max-w-2xl flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-heading-sm">Sign-ups and activations</span>
<span className="text-body-xs text-muted-foreground">
Accounts per month, 2026
</span>
</div>
<ChartContainer config={config} className="aspect-auto h-72 w-full">
<BarChart
data={data}
barGap={2}
barCategoryGap="28%"
margin={{ left: 0, right: 8, top: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={52}
tickFormatter={(value: number) => number.format(value)}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
<Bar
dataKey="signups"
fill="var(--color-signups)"
radius={[4, 4, 0, 0]}
maxBarSize={24}
/>
<Bar
dataKey="activated"
fill="var(--color-activated)"
radius={[4, 4, 0, 0]}
maxBarSize={24}
/>
</BarChart>
</ChartContainer>
</div>
)
}Stacked
For part-to-whole comparisons across many categories, stack horizontally. A 2px stroke in the surface color separates segments — the gap does the separating, not an outline.
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const data = [
{ team: "Platform", production: 3120, preview: 1840, development: 620 },
{ team: "Web", production: 2410, preview: 2260, development: 910 },
{ team: "Mobile", production: 1680, preview: 1190, development: 1340 },
{ team: "Data", production: 2890, preview: 640, development: 480 },
{ team: "Growth", production: 1240, preview: 1580, development: 390 },
]
const config = {
production: { label: "Production", color: "var(--chart-1)" },
preview: { label: "Preview", color: "var(--chart-2)" },
development: { label: "Development", color: "var(--chart-3)" },
} satisfies ChartConfig
const number = new Intl.NumberFormat("en-US")
const keys = ["production", "preview", "development"] as const
export default function ChartStacked() {
return (
<div className="flex w-full max-w-2xl flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-heading-sm">Build minutes by environment</span>
<span className="text-body-xs text-muted-foreground">
September 2026, per team
</span>
</div>
<ChartContainer config={config} className="aspect-auto h-72 w-full">
<BarChart
data={data}
layout="vertical"
margin={{ left: 0, right: 12, top: 12 }}
>
<CartesianGrid horizontal={false} />
<YAxis
dataKey="team"
type="category"
tickLine={false}
axisLine={false}
tickMargin={8}
width={68}
/>
<XAxis
type="number"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => number.format(value)}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
{keys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="minutes"
fill={`var(--color-${key})`}
// The surface-colored stroke is the 2px gap between segments.
stroke="var(--background)"
strokeWidth={2}
maxBarSize={24}
radius={index === keys.length - 1 ? [0, 4, 4, 0] : 0}
/>
))}
</BarChart>
</ChartContainer>
</div>
)
}Donut
Donuts show part-to-whole at a glance for up to six segments. For comparing close values, use a bar chart — angles are hard to compare.
"use client"
import { Label, Pie, PieChart } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
const data = [
{ source: "organic", visits: 18420, fill: "var(--color-organic)" },
{ source: "direct", visits: 11260, fill: "var(--color-direct)" },
{ source: "referral", visits: 6890, fill: "var(--color-referral)" },
{ source: "social", visits: 4310, fill: "var(--color-social)" },
]
const config = {
visits: { label: "Visits" },
organic: { label: "Organic search", color: "var(--chart-1)" },
direct: { label: "Direct", color: "var(--chart-2)" },
referral: { label: "Referral", color: "var(--chart-3)" },
social: { label: "Social", color: "var(--chart-4)" },
} satisfies ChartConfig
const total = data.reduce((sum, item) => sum + item.visits, 0)
const totalLabel = "Total visits"
export default function ChartDonut() {
return (
<div className="flex w-full max-w-sm flex-col items-center gap-2">
<span className="text-heading-sm">Traffic by source</span>
<ChartContainer config={config} className="aspect-square h-72 w-full">
<PieChart>
<ChartTooltip
content={<ChartTooltipContent nameKey="source" hideLabel />}
/>
<Pie
data={data}
dataKey="visits"
nameKey="source"
innerRadius="58%"
outerRadius="82%"
stroke="var(--background)"
strokeWidth={2}
>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox)) return null
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-2xl font-semibold"
>
{total.toLocaleString("en-US")}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy ?? 0) + 22}
className="fill-muted-foreground text-xs"
>
{totalLabel}
</tspan>
</text>
)
}}
/>
</Pie>
<ChartLegend
content={
<ChartLegendContent nameKey="source" className="flex-wrap" />
}
/>
</PieChart>
</ChartContainer>
</div>
)
}Stat with sparkline
Often the right chart is a number. A stat tile leads with the value, states the change against a named period, and adds a 12-point sparkline: history in a quiet gray, the current period in the accent. Color the change by whether it's good news, and pair it with an arrow so direction never depends on color.
"use client"
import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react"
import { Line, LineChart, YAxis } from "recharts"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
import { cn } from "@/lib/utils"
type Stat = {
label: string
value: string
delta: string
direction: "up" | "down"
/** Whether the direction is good news — drives the delta color. */
good: boolean
trend: number[]
}
const stats: Stat[] = [
{
label: "Recurring revenue",
value: "$84,320",
delta: "+8.2%",
direction: "up",
good: true,
trend: [61, 63, 62, 66, 68, 67, 71, 73, 72, 77, 80, 84],
},
{
label: "p95 latency",
value: "182 ms",
delta: "−24 ms",
direction: "down",
good: true,
trend: [240, 236, 228, 231, 219, 214, 209, 206, 198, 195, 190, 182],
},
{
label: "Error rate",
value: "0.42%",
delta: "+0.09 pt",
direction: "up",
good: false,
trend: [
0.3, 0.31, 0.29, 0.33, 0.32, 0.35, 0.34, 0.36, 0.33, 0.38, 0.4, 0.42,
],
},
]
const config = {
value: { label: "Value", color: "var(--gray-8)" },
} satisfies ChartConfig
function Sparkline({ trend }: { trend: number[] }) {
const data = trend.map((value, index) => ({ index, value }))
const last = data.length - 1
return (
<ChartContainer
config={config}
className="aspect-auto h-10 w-full"
aria-hidden="true"
>
{/* Decorative: hidden from assistive technology, so it mustn't take focus either. */}
<LineChart
data={data}
margin={{ top: 5, right: 5, bottom: 5, left: 5 }}
accessibilityLayer={false}
>
{/* A sparkline shows shape, not magnitude: fit the range, not zero. */}
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
dataKey="value"
type="monotone"
stroke="var(--color-value)"
strokeWidth={2}
strokeLinecap="round"
isAnimationActive={false}
dot={(props: { cx?: number; cy?: number; index?: number }) =>
props.index === last ? (
<circle
key="current"
cx={props.cx}
cy={props.cy}
r={4}
fill="var(--chart-1)"
stroke="var(--card)"
strokeWidth={2}
/>
) : (
<g key={props.index} />
)
}
/>
</LineChart>
</ChartContainer>
)
}
export default function ChartStat() {
return (
<div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
{stats.map((stat) => {
const Icon =
stat.direction === "up" ? ArrowUpRightIcon : ArrowDownRightIcon
return (
<div
key={stat.label}
className="flex flex-col gap-2 rounded-xl border bg-card p-4 shadow-raised"
>
<span className="truncate text-body-xs text-muted-foreground">
{stat.label}
</span>
{/* Proportional figures: tabular digits look loose at display size. */}
<span className="text-heading-lg whitespace-nowrap">
{stat.value}
</span>
<span
className={cn(
"flex items-center gap-1 text-xs font-medium whitespace-nowrap",
stat.good ? "text-success-11" : "text-danger-11"
)}
>
<Icon className="size-3.5" aria-hidden="true" />
{stat.delta}
<span className="font-normal text-muted-foreground">
vs. August
</span>
</span>
<Sparkline trend={stat.trend} />
</div>
)
})}
</div>
)
}Emphasis
When the point is one series, say so: show it in the accent and everything else in gray. This is the most underused chart form, and often the honest answer to "make this chart clearer".
"use client"
import { Bar, BarChart, Cell, LabelList, XAxis } from "recharts"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
const data = [
{ region: "Frankfurt", ms: 38 },
{ region: "London", ms: 41 },
{ region: "Virginia", ms: 92 },
{ region: "São Paulo", ms: 141 },
{ region: "Singapore", ms: 188 },
{ region: "Sydney", ms: 214 },
]
const highlight = "Sydney"
const config = {
ms: { label: "Median latency (ms)", color: "var(--gray-8)" },
} satisfies ChartConfig
export default function ChartEmphasis() {
return (
<div className="flex w-full max-w-2xl flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-heading-sm">
Sydney waits 5.6× longer than Frankfurt
</span>
<span className="text-body-xs text-muted-foreground">
Median latency from Kraków, in milliseconds
</span>
</div>
<ChartContainer config={config} className="aspect-auto h-64 w-full">
<BarChart data={data} margin={{ top: 24, left: 8, right: 8 }}>
<XAxis
dataKey="region"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<Bar
dataKey="ms"
radius={[4, 4, 0, 0]}
maxBarSize={24}
isAnimationActive={false}
>
{data.map((item) => (
<Cell
key={item.region}
fill={
item.region === highlight
? "var(--chart-1)"
: "var(--color-ms)"
}
/>
))}
<LabelList
dataKey="ms"
position="top"
offset={8}
className="fill-muted-foreground text-xs"
/>
</Bar>
</BarChart>
</ChartContainer>
</div>
)
}With a table view
Every chart should have a table twin. It's the most accessible form of the data, the one people copy from, and it keeps values reachable without hovering.
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const data = [
{ quarter: "Q4 2025", tickets: 1284 },
{ quarter: "Q1 2026", tickets: 1172 },
{ quarter: "Q2 2026", tickets: 968 },
{ quarter: "Q3 2026", tickets: 811 },
]
const config = {
tickets: { label: "Support tickets", color: "var(--chart-1)" },
} satisfies ChartConfig
const number = new Intl.NumberFormat("en-US")
export default function ChartWithTable() {
return (
<Tabs defaultValue="chart" className="w-full max-w-xl">
<div className="flex items-center justify-between gap-4">
<span className="text-heading-sm">Support tickets per quarter</span>
<TabsList>
<TabsTrigger value="chart">Chart</TabsTrigger>
<TabsTrigger value="table">Table</TabsTrigger>
</TabsList>
</div>
<TabsContent value="chart">
<ChartContainer config={config} className="aspect-auto h-56 w-full">
<BarChart data={data} margin={{ left: 0, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="quarter"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
width={52}
tickFormatter={(value: number) => number.format(value)}
/>
<ChartTooltip content={<ChartTooltipContent hideIndicator />} />
<Bar
dataKey="tickets"
fill="var(--color-tickets)"
radius={[4, 4, 0, 0]}
maxBarSize={24}
/>
</BarChart>
</ChartContainer>
</TabsContent>
<TabsContent value="table">
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Quarter</TableHead>
<TableHead scope="col" className="text-right">
Tickets
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow key={row.quarter}>
<TableCell>{row.quarter}</TableCell>
<TableCell className="text-right">
{number.format(row.tickets)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
</Tabs>
)
}Color
Color comes last: pick the form first, then give each color exactly one job.
Categorical palette
chart-1chart-2chart-3chart-4chart-5The order — brand, teal, violet, orange, pink — isn't aesthetic. It was chosen by enumerating every ordering and keeping the one with the largest color difference between neighbors under simulated protanopia and deuteranopia. The four hues sit in the legible lightness band and clear the chroma floor; the first series is the brand color, black or white by default, and stands apart from them by lightness. Every series keeps at least 3:1 contrast with the chart surface, in both modes. tests/tokens.test.ts recomputes these figures from the tokens:
Rules that follow from this:
- Assign in order, never skip, never cycle. The first series is always
chart-1. A sixth series folds into Other, becomes a small multiple, or moves to a table — never a generated sixth hue. - Color follows the entity, not its rank. When a filter removes a series, the others keep their colors.
- Scatter plots and maps get three series at most — there, any two colors can end up side by side.
- Status colors are reserved. Green and red mean good and bad everywhere in prfct; never use them for "series 4".
Guidelines
Choose the form first
Quiet chrome
Data is the only thing allowed to be loud. Gridlines are solid hairlines one step off the surface — never dashed. Axis labels use muted-foreground. Remove axis lines and tick marks; keep ticks at clean, comma-separated numbers.
One axis
Never plot two measures on two y-axes: the alignment of the scales is arbitrary, so the chart invents correlations. Use two charts, small multiples, or index both series to 100 at the start.
Labels and legends
- A legend is always present for two or more series; a single series is named by the title.
- Label selectively — the latest value, the extreme, the one series the story is about. On lines and dense charts, never put a number on every point; a handful of bars without a value axis can each carry their value, as in Emphasis.
- Text wears text tokens. Values, labels and legends stay in
foregroundormuted-foreground; the colored mark beside them carries identity.
Accessibility
- Keyboard. Recharts' accessibility layer is on by default: focus a chart with Tab, which draws a focus ring around it, and move between data points with the arrow keys; the tooltip follows focus exactly as it follows the pointer.
- A text alternative. Pair every chart with a table view, or describe the takeaway in its title and caption — "Signups grew 64% since April" says more than "Signups by month".
- Never color alone. Legends and direct labels identify series; status changes pair color with an icon and words.
- Contrast. Every categorical color keeps at least 3:1 against the chart surface in both modes.
- Motion. Recharts animations default to
"auto", which respectsprefers-reduced-motion. Keep it.
| Key | Behavior |
|---|---|
Tab | Moves focus to the chart. |
←→ | Moves between data points and updates the tooltip. |
API reference
ChartContainer
Wraps a Recharts chart, scopes its colors and makes it responsive. Accepts all div props.
ChartConfig
type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string } // one color for both modes
| { theme: { light: string; dark: string } } // a color per mode
)
>ChartTooltipContent
Use as <ChartTooltip content={<ChartTooltipContent />} />.
ChartLegendContent
Use as <ChartLegend content={<ChartLegendContent />} />. ChartLegend lists the series in the order of the keys in config, the same order the colors are assigned in, where Recharts alone would sort them alphabetically. Pass itemSorter to choose another order.
ChartStyle
Injected by ChartContainer; exported for custom containers that need the same scoped color variables.