Chart
Charts built on Recharts: a config-driven container plus tooltip and legend bodies, styled with the design tokens.
'use client';
import * as stylex from '@stylexjs/stylex';
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts';
import { container, fontSize } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const revenue = [
{ month: 'Jan', revenue: 18600 },
{ month: 'Feb', revenue: 30500 },
{ month: 'Mar', revenue: 23700 },
{ month: 'Apr', revenue: 7300 },
{ month: 'May', revenue: 20900 },
{ month: 'Jun', revenue: 21400 },
];
// The config is the single source for the series label and its color: the
// tooltip reads the label from here, and the mark below is filled from the
// same entry.
const chartConfig = {
revenue: { label: 'Revenue', color: colors.chart1 },
} satisfies ChartConfig;
export default function ChartDemo() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<BarChart accessibilityLayer data={revenue} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
<ChartTooltip
cursor={{ fill: colors.accent }}
content={<ChartTooltipContent />}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{/* Rounded only at the data end, so the bar stays anchored to the baseline. */}
<Bar dataKey="revenue" fill={chartConfig.revenue.color} radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
);
}
const styles = stylex.create({
chart: {
maxWidth: container.xxl,
},
});
Install
npx @madeui/cli add chart
Installs recharts alongside the component.
Usage
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
useChart,
type ChartConfig,
} from '@/components/ui/chart';
'use client';
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts';
import { colors } from '@/lib/tokens.stylex';
const chartConfig = {
revenue: { label: 'Revenue', color: colors.chart1 },
} satisfies ChartConfig;
<ChartContainer config={chartConfig}>
<BarChart accessibilityLayer data={rows}>
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="revenue" fill={chartConfig.revenue.color} radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>;
The config is the one place a series is named and colored. ChartTooltipContent and ChartLegendContent read the label and the swatch color out of it, and the mark is filled from the same entry — so a series never drifts between the plot and the chrome. Keys are the series’ dataKey; on a pie or a radial bar, where every slice comes from one series, they are the values of its nameKey instead.
Marks, axes, grids, and the chart element itself are Recharts’ own components, composed directly. Nothing here wraps them.
Composition
<ChartContainer config={chartConfig}>
<BarChart>
<CartesianGrid />
<XAxis />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar />
</BarChart>
</ChartContainer>
Sizing
ChartContainer is a full-width box with an aspect-ratio of 16 / 9 and no height of its own. Change the ratio through style; a square suits a donut or a radar, and a wide, flat one suits a sparkline.
const styles = stylex.create({
chart: { aspectRatio: '1', maxWidth: container.md },
});
<ChartContainer config={chartConfig} style={styles.chart}>
Reach for a fixed height only when the surrounding layout already fixes one. A ratio keeps the plot proportionate at every width, and it is what reserves the box before the chart mounts.
Line
Two series, so the config carries two entries and a legend names both. type="monotone" smooths the path without letting it overshoot a data point, and the points stay undotted: the tooltip already marks the hovered month on every line, which is the only place a dot carries information.
'use client';
import * as stylex from '@stylexjs/stylex';
import { CartesianGrid, Line, LineChart, XAxis } from 'recharts';
import { container, fontSize } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const visitors = [
{ month: 'Jan', desktop: 186, mobile: 80 },
{ month: 'Feb', desktop: 305, mobile: 200 },
{ month: 'Mar', desktop: 237, mobile: 120 },
{ month: 'Apr', desktop: 173, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'Jun', desktop: 214, mobile: 140 },
];
// Two series, so the config carries two entries and the legend names both.
const chartConfig = {
desktop: { label: 'Desktop', color: colors.chart1 },
mobile: { label: 'Mobile', color: colors.chart2 },
} satisfies ChartConfig;
export default function ChartLine() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<LineChart accessibilityLayer data={visitors} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
<ChartTooltip
cursor={{ stroke: colors.border }}
content={<ChartTooltipContent />}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{/* No per-point dots: the tooltip already marks the hovered month on
every line, which is the only place a dot carries information. */}
<Line
dataKey="desktop"
type="monotone"
stroke={chartConfig.desktop.color}
strokeWidth={2}
dot={false}
/>
<Line
dataKey="mobile"
type="monotone"
stroke={chartConfig.mobile.color}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
);
}
const styles = stylex.create({
chart: {
maxWidth: container.xxl,
},
});
Stacked area
A shared stackId stacks the layers and pins their order, so they never swap between updates. The fills are translucent; the stroke keeps each band’s top edge legible where two of them meet.
'use client';
import * as stylex from '@stylexjs/stylex';
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
import { container, fontSize } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const signups = [
{ month: 'Jan', free: 420, pro: 120 },
{ month: 'Feb', free: 460, pro: 150 },
{ month: 'Mar', free: 510, pro: 190 },
{ month: 'Apr', free: 480, pro: 230 },
{ month: 'May', free: 560, pro: 260 },
{ month: 'Jun', free: 610, pro: 310 },
];
const chartConfig = {
free: { label: 'Free', color: colors.chart1 },
pro: { label: 'Pro', color: colors.chart2 },
} satisfies ChartConfig;
export default function ChartArea() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<AreaChart accessibilityLayer data={signups} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
<ChartTooltip
cursor={{ stroke: colors.border }}
content={<ChartTooltipContent indicator="line" />}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{/* A shared `stackId` stacks the layers and pins their order, so they
never swap between updates. The stroke keeps each band's top edge
legible where two translucent fills meet. */}
<Area
dataKey="free"
type="monotone"
stackId="signups"
stroke={chartConfig.free.color}
strokeWidth={2}
fill={chartConfig.free.color}
fillOpacity={0.3}
/>
<Area
dataKey="pro"
type="monotone"
stackId="signups"
stroke={chartConfig.pro.color}
strokeWidth={2}
fill={chartConfig.pro.color}
fillOpacity={0.3}
/>
</AreaChart>
</ChartContainer>
);
}
const styles = stylex.create({
chart: {
maxWidth: container.xxl,
},
});
Donut
innerRadius opens the hole, and a <Cell> per row takes its fill from the config. A stroke in the surface color separates the slices, so the ring reads as one shape cut into parts. The centre total is a <Label>, which scales with the plot and never covers a slice.
'use client';
import * as stylex from '@stylexjs/stylex';
import { Cell, Label, Pie, PieChart } from 'recharts';
import { container, fontSize, fontWeight } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const sessions = [
{ browser: 'chrome', sessions: 6240 },
{ browser: 'safari', sessions: 1910 },
{ browser: 'firefox', sessions: 820 },
{ browser: 'edge', sessions: 640 },
{ browser: 'other', sessions: 510 },
];
// Every slice comes from one series, so the config is keyed by the values of
// the `nameKey` field rather than by a `dataKey`.
const chartConfig = {
chrome: { label: 'Chrome', color: colors.chart1 },
safari: { label: 'Safari', color: colors.chart2 },
firefox: { label: 'Firefox', color: colors.chart3 },
edge: { label: 'Edge', color: colors.chart4 },
// Slot 6 rather than 5: the two smallest slices sit next to each other in
// the ring, and slots 4 and 5 are close enough in hue to blur there.
other: { label: 'Other', color: colors.chart6 },
} satisfies ChartConfig;
const count = new Intl.NumberFormat('en-US');
const total = sessions.reduce((sum, row) => sum + row.sessions, 0);
export default function ChartPie() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<PieChart>
{/* The slices carry their own labels, so the tooltip needs no title. */}
<ChartTooltip content={<ChartTooltipContent nameKey="browser" hideLabel />} />
<Pie
data={sessions}
dataKey="sessions"
nameKey="browser"
innerRadius="58%"
// A stroke in the surface color separates the slices, so the ring
// reads as one shape cut into parts rather than five loose arcs.
stroke={colors.background}
strokeWidth={2}
>
{sessions.map((row) => (
<Cell key={row.browser} fill={chartConfig[row.browser as Browser].color} />
))}
<Label content={<CentreTotal />} />
</Pie>
<ChartLegend
itemSorter={null}
content={<ChartLegendContent nameKey="browser" />}
/>
</PieChart>
</ChartContainer>
);
}
type Browser = keyof typeof chartConfig;
// The centre of a donut is empty, so the total goes there as chart text: it
// scales with the plot and never covers a slice.
function CentreTotal({ viewBox }: { viewBox?: unknown }) {
const box = viewBox as { cx?: number; cy?: number } | undefined;
if (box?.cx == null || box.cy == null) return null;
return (
<text x={box.cx} y={box.cy} textAnchor="middle" dominantBaseline="middle">
<tspan
x={box.cx}
y={box.cy}
fill={colors.foreground}
fontSize={fontSize.xl}
fontWeight={fontWeight.semibold}
>
{count.format(total)}
</tspan>
{/* One 24px step below the number, in SVG user units. */}
<tspan
x={box.cx}
y={box.cy + 24}
fill={colors.mutedForeground}
fontSize={fontSize.xs}
>
Sessions
</tspan>
</text>
);
}
const styles = stylex.create({
chart: {
aspectRatio: '1',
maxWidth: container.lg,
},
});
Radar
PolarGrid and PolarAngleAxis draw the web and its labels. Two overlapping shapes need light fills and a visible outline — the outline is what carries the comparison.
'use client';
import * as stylex from '@stylexjs/stylex';
import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from 'recharts';
import { container, fontSize } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
// Short axis labels: they sit outside the web, and a long one is the first
// thing to be clipped when the box narrows.
const scores = [
{ area: 'Speed', current: 92, previous: 78 },
{ area: 'Design', current: 88, previous: 81 },
{ area: 'Support', current: 74, previous: 70 },
{ area: 'Docs', current: 96, previous: 90 },
{ area: 'Pricing', current: 61, previous: 55 },
];
const chartConfig = {
current: { label: 'This release', color: colors.chart1 },
previous: { label: 'Previous', color: colors.chart2 },
} satisfies ChartConfig;
export default function ChartRadar() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
{/* A percentage outer radius leaves room for the labels at every width;
a fixed margin only works at the width it was chosen for. */}
<RadarChart accessibilityLayer data={scores} outerRadius="72%">
<PolarGrid stroke={colors.border} />
<PolarAngleAxis
dataKey="area"
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{/* Two filled shapes on top of each other muddy both. The reference
shape is an outline — dashed, so the comparison survives without
color — and only the current one is filled. */}
<Radar
dataKey="previous"
stroke={chartConfig.previous.color}
strokeWidth={2}
strokeDasharray="4 4"
fill="none"
/>
<Radar
dataKey="current"
stroke={chartConfig.current.color}
strokeWidth={2}
fill={chartConfig.current.color}
fillOpacity={0.2}
/>
</RadarChart>
</ChartContainer>
);
}
const styles = stylex.create({
chart: {
aspectRatio: '1',
maxWidth: container.lg,
},
});
Radial
One ring per category, longest first. A radial bar’s length is harder to compare than a straight one, so the order does the work a shared baseline does on a bar chart, and background traces the full ring so each bar reads as a share of it.
'use client';
import * as stylex from '@stylexjs/stylex';
import { Cell, RadialBar, RadialBarChart } from 'recharts';
import { container } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const adoption = [
{ plan: 'starter', accounts: 1840 },
{ plan: 'team', accounts: 1290 },
{ plan: 'business', accounts: 760 },
{ plan: 'enterprise', accounts: 310 },
];
const chartConfig = {
starter: { label: 'Starter', color: colors.chart1 },
team: { label: 'Team', color: colors.chart2 },
business: { label: 'Business', color: colors.chart3 },
enterprise: { label: 'Enterprise', color: colors.chart4 },
} satisfies ChartConfig;
export default function ChartRadial() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<RadialBarChart
data={adoption}
innerRadius="28%"
outerRadius="96%"
startAngle={90}
endAngle={-270}
>
<ChartTooltip content={<ChartTooltipContent nameKey="plan" hideLabel />} />
{/* One ring per category, longest first: a radial bar's length is
harder to compare than a straight one, so the order does the work
the shared baseline does on a bar chart. The background traces each
full ring, so a bar reads as a share of it and needs no gridlines. */}
<RadialBar dataKey="accounts" background={{ fill: colors.muted }} cornerRadius={4}>
{adoption.map((row) => (
<Cell key={row.plan} fill={chartConfig[row.plan as Plan].color} />
))}
</RadialBar>
<ChartLegend
itemSorter={null}
content={<ChartLegendContent nameKey="plan" />}
/>
</RadialBarChart>
</ChartContainer>
);
}
type Plan = keyof typeof chartConfig;
const styles = stylex.create({
chart: {
aspectRatio: '1',
maxWidth: container.md,
},
});
Legend
ChartLegend is the chart’s legend; ChartLegendContent is its body, and it takes each entry’s label and swatch color from the config. verticalAlign reaches the body, which pads on the side facing the plot.
'use client';
import * as stylex from '@stylexjs/stylex';
import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts';
import { container, fontSize } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const sales = [
{ quarter: 'Q1', europe: 120, americas: 98, asia: 64 },
{ quarter: 'Q2', europe: 135, americas: 110, asia: 82 },
{ quarter: 'Q3', europe: 128, americas: 125, asia: 97 },
{ quarter: 'Q4', europe: 150, americas: 140, asia: 115 },
];
const chartConfig = {
europe: { label: 'Europe', color: colors.chart1 },
americas: { label: 'Americas', color: colors.chart2 },
asia: { label: 'Asia', color: colors.chart3 },
} satisfies ChartConfig;
export default function ChartLegendExample() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<BarChart accessibilityLayer data={sales} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis
dataKey="quarter"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
<ChartTooltip
cursor={{ fill: colors.accent }}
content={<ChartTooltipContent />}
/>
{/* `verticalAlign` reaches the body, which pads on the side facing the
plot; the entries take their label and color from the config. */}
<ChartLegend
itemSorter={null}
verticalAlign="top"
content={<ChartLegendContent />}
/>
<Bar dataKey="europe" fill={chartConfig.europe.color} radius={[4, 4, 0, 0]} />
<Bar dataKey="americas" fill={chartConfig.americas.color} radius={[4, 4, 0, 0]} />
<Bar dataKey="asia" fill={chartConfig.asia.color} radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
);
}
const styles = stylex.create({
chart: {
maxWidth: container.xxl,
},
});
Tooltip
labelFormatter rewrites the title, formatter rewrites a row’s value, and indicator="line" swaps the dot for a bar as tall as its row — the shape to reach for when the rows carry a series stroke.
'use client';
import * as stylex from '@stylexjs/stylex';
import { CartesianGrid, Line, LineChart, XAxis } from 'recharts';
import { container, fontSize } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
const revenue = [
{ month: 'Jan', subscriptions: 42100, services: 18300 },
{ month: 'Feb', subscriptions: 44800, services: 21900 },
{ month: 'Mar', subscriptions: 47600, services: 19700 },
{ month: 'Apr', subscriptions: 51200, services: 24400 },
{ month: 'May', subscriptions: 53900, services: 26100 },
{ month: 'Jun', subscriptions: 58300, services: 27800 },
];
const chartConfig = {
subscriptions: { label: 'Subscriptions', color: colors.chart1 },
services: { label: 'Services', color: colors.chart2 },
} satisfies ChartConfig;
const currency = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
export default function ChartTooltipExample() {
return (
<ChartContainer config={chartConfig} style={styles.chart}>
<LineChart accessibilityLayer data={revenue} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
<ChartTooltip
cursor={{ stroke: colors.border }}
content={
<ChartTooltipContent
indicator="line"
labelFormatter={(month) => `${month} 2026`}
formatter={(value) => currency.format(Number(value))}
/>
}
/>
<Line
dataKey="subscriptions"
type="monotone"
stroke={chartConfig.subscriptions.color}
strokeWidth={2}
dot={false}
/>
<Line
dataKey="services"
type="monotone"
stroke={chartConfig.services.color}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
);
}
const styles = stylex.create({
chart: {
maxWidth: container.xxl,
},
});
Sparkline
No axes, no grid, no tooltip: the plot is decoration next to the number that carries the value, and a zero margin lets it fill the box edge to edge. The scale spans the data rather than starting at zero — a sparkline reads as shape, and a zero baseline flattens it. A hidden <YAxis> is what carries that domain when no axis is drawn.
Active users
1,690
Up 43% over 14 days
'use client';
import * as stylex from '@stylexjs/stylex';
import { Area, AreaChart, YAxis } from 'recharts';
import { space, fontSize, fontWeight, lineHeight, container } from '@/lib/constants.stylex';
import { colors } from '@/lib/tokens.stylex';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, type ChartConfig } from '@/components/ui/chart';
const activeUsers = [
{ day: 1, users: 1180 },
{ day: 2, users: 1240 },
{ day: 3, users: 1195 },
{ day: 4, users: 1310 },
{ day: 5, users: 1290 },
{ day: 6, users: 1385 },
{ day: 7, users: 1360 },
{ day: 8, users: 1440 },
{ day: 9, users: 1420 },
{ day: 10, users: 1510 },
{ day: 11, users: 1475 },
{ day: 12, users: 1580 },
{ day: 13, users: 1620 },
{ day: 14, users: 1690 },
];
const chartConfig = {
users: { label: 'Active users', color: colors.chart1 },
} satisfies ChartConfig;
// A sparkline reads as shape, not magnitude, so the scale spans the data
// rather than starting at zero — a zero baseline flattens a 43% rise into a
// straight line. The tenth-of-a-range padding keeps the stroke off the edges.
const users = activeUsers.map((point) => point.users);
const padding = (Math.max(...users) - Math.min(...users)) / 10;
const domain: [number, number] = [
Math.min(...users) - padding,
Math.max(...users) + padding,
];
export default function ChartSparkline() {
return (
<Card style={styles.card}>
<CardHeader>
<CardDescription>Active users</CardDescription>
<CardTitle style={styles.value}>1,690</CardTitle>
</CardHeader>
<CardContent>
{/* No axes, no grid, no tooltip: the plot is decoration next to the
number that carries the value. The margin is only what keeps the
stroke off the edges it would otherwise be clipped by. */}
<ChartContainer config={chartConfig} style={styles.chart}>
<AreaChart
data={activeUsers}
margin={{ top: 2, right: 2, bottom: 0, left: 2 }}
>
<defs>
<linearGradient id="sparkline-users" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartConfig.users.color} stopOpacity={0.8} />
<stop offset="100%" stopColor={chartConfig.users.color} stopOpacity={0.05} />
</linearGradient>
</defs>
{/* A hidden axis is what carries the domain when none is drawn. */}
<YAxis hide domain={domain} />
<Area
dataKey="users"
type="monotone"
baseValue={domain[0]}
stroke={chartConfig.users.color}
strokeWidth={2}
fill="url(#sparkline-users)"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
<p {...stylex.props(styles.caption)}>Up 43% over 14 days</p>
</CardContent>
</Card>
);
}
const styles = stylex.create({
card: {
width: container.md,
},
chart: {
aspectRatio: '4',
},
value: {
fontSize: fontSize.xl,
fontVariantNumeric: 'tabular-nums',
fontWeight: fontWeight.semibold,
lineHeight: lineHeight.tight,
},
caption: {
color: colors.mutedForeground,
fontSize: fontSize.xs,
lineHeight: lineHeight.snug,
margin: 0,
marginBlockStart: space.s2,
},
});
API reference
Built on Recharts. The chart element, marks, axes, grids, and cursors are Recharts’ own components, composed directly and documented in the Recharts API reference; this component adds the config, the container, and the tooltip and legend bodies.
No part takes variant or size: a chart has no meaningful preset axis, and sizing belongs to the container’s aspect ratio.
Pass accessibilityLayer on the chart element. It makes the plot focusable and steps through the data with the arrow keys, moving the tooltip with the keyboard focus, and it announces each point to a screen reader.
The focus ring on a chart is the browser’s own, not the ring the rest of the library draws: the focusable element is the <svg> Recharts renders, which no part here owns, and StyleX styles only elements it puts a class on. It is a visible keyboard focus indicator, so it is left as it is rather than replaced with something that would have to reach across a component boundary to work.
ChartConfig
One entry per series, keyed by dataKey — or, where every mark comes from one series, by the values of its nameKey.
| Field | Type | Description |
|---|---|---|
label |
ReactNode |
Printed by the tooltip and the legend. |
color |
string |
The series color. Use the chart1 … chart6 tokens, which are themed for light and dark. |
icon |
ComponentType |
Rendered in the legend in place of the swatch. |
The color lives here and is read back where the mark is declared — fill={chartConfig.revenue.color} — so one value reaches the plot, the tooltip, and the legend.
ChartContainer
Provides the config to the tooltip and legend bodies, sets the font and color the chart’s text inherits, and measures the plot. Its single child is the chart element.
| Prop | Type | Default | Description |
|---|---|---|---|
config |
ChartConfig |
— | Series labels and colors (required). |
children |
ReactElement |
— | The chart element, e.g. <BarChart> (required). |
style |
StyleXStyles |
— | StyleX styles merged last — always win over the component’s own styles. Override aspectRatio here. |
All native div props are forwarded.
ChartTooltip
Recharts’ Tooltip, re-exported. Give it a body with content={<ChartTooltipContent />}, and set cursor to a token so the hover marker matches the theme: cursor={{ fill: colors.accent }} for bars, cursor={{ stroke: colors.border }} for lines and areas, cursor={false} where none is wanted.
ChartTooltipContent
| Prop | Type | Default | Description |
|---|---|---|---|
indicator |
'dot' | 'line' |
'dot' |
Swatch shape next to each row. |
hideLabel |
boolean |
false |
Omits the title. |
hideIndicator |
boolean |
false |
Omits the swatches. |
labelKey |
string |
— | Config key for the title, when the axis value is not one. |
nameKey |
string |
— | Config key for every row’s label, when the dataKey is not one. |
formatter |
(value, name, item, index, payload) => ReactNode |
— | Replaces a row’s value. |
labelFormatter |
(label, payload) => ReactNode |
— | Replaces the title. |
style |
StyleXStyles |
— | StyleX styles merged last — always win over the component’s own styles. |
active, payload, and label are injected by ChartTooltip; you do not pass them.
ChartLegend
Recharts’ Legend, re-exported. Give it a body with content={<ChartLegendContent />}.
ChartLegendContent
| Prop | Type | Default | Description |
|---|---|---|---|
hideIcon |
boolean |
false |
Omits the swatches. |
nameKey |
string |
— | Config key for every entry’s label, when the dataKey is not one. |
style |
StyleXStyles |
— | StyleX styles merged last — always win over the component’s own styles. |
payload and verticalAlign are injected by ChartLegend.
useChart
const { config } = useChart();
The config of the nearest ChartContainer, for a tooltip or legend body of your own. Throws outside one.
Styling
ChartContainer, ChartTooltipContent, and ChartLegendContent accept style (StyleXStyles, merged last so caller overrides always win).
The plot itself is painted by Recharts as SVG, so its colors and geometry are props on the marks and guides rather than CSS. Tokens work there directly — they compile to var(), which SVG paint resolves the same way CSS does:
<CartesianGrid vertical={false} stroke={colors.border} strokeDasharray="4 4" />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fill: colors.mutedForeground, fontSize: fontSize.xs }}
/>
Sizes on a mark — strokeWidth, radius, tickMargin, cornerRadius — are plain numbers in SVG user units, not tokens.