Your chart library doesn't need a dark mode
A Recharts chart can follow your page's dark mode with no theme state and no re-render: it names CSS variables, and the page declares them. This article shows the container that declares them, the trap that catches the hand-written version, and what still needs a line of your own.
The problem
A chart library takes its colours as strings — stroke="#0ea5e9" — so nothing your page does to its own theme reaches the chart. Your cards turn dark and the chart keeps its light-mode lines, black axis labels and a white tooltip. The fixes land one bug at a time: axis labels in one project, tooltips in another, labels, series and tooltips in a third, all in the last three months. Recharts itself discussed a theming API through most of 2026 and is shipping one in its 3.11 pre-releases, marked experimental.
The usual fixes each cost something:
- Pick the colour in React:
stroke={theme === 'dark' ? '#38bdf8' : '#0ea5e9'}. Every chart reads the theme, so every chart re-renders on a switch, and a server render has to guess the theme. - Variables in a global stylesheet. This is the right mechanism, and it works until a section of the page has a theme of its own. More on that below.
The chart names variables, the container declares them
import { ChartContainer } from '@box-kite/react/components/chart';
import { Line, LineChart, ResponsiveContainer } from 'recharts';
const months = [
{ month: 'Jan', revenue: 12, cost: 8 },
{ month: 'Feb', revenue: 18, cost: 9 },
];
export function Revenue() {
return (
<ChartContainer series={['revenue', 'cost']} height={30}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={months}>
<Line dataKey="revenue" stroke="var(--color-revenue)" />
<Line dataKey="cost" stroke="var(--color-cost)" />
</LineChart>
</ResponsiveContainer>
</ChartContainer>
);
}ChartContainer is a Box that declares a numbered palette, --chart-1 to --chart-6, with a lighter twin of each under the dark theme. It also declares one --color-<series> per name, pointing at the palette in order. The chart names no colour at all, so its dark mode is the page's.
What a switch costs. We put a render counter in the chart component and flipped the page from light to dark: every line changed colour, and the chart rendered zero more times. The theme is a class on an ancestor, and the browser re-resolves the variables. React is not involved.
The rest of the chart
The palette covers the series. The grid, the axis labels and the tooltip are yours, and they work the same way: declare a variable on the container with vars, give it a dark value under theme, and name it in the chart.
import Box from '@box-kite/react';
import { ChartContainer } from '@box-kite/react/components/chart';
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { TooltipContentProps } from 'recharts';
const months = [
{ month: 'Jan', revenue: 12, cost: 8 },
{ month: 'Feb', revenue: 18, cost: 9 },
{ month: 'Mar', revenue: 15, cost: 11 },
{ month: 'Apr', revenue: 22, cost: 12 },
{ month: 'May', revenue: 26, cost: 13 },
{ month: 'Jun', revenue: 31, cost: 15 },
];
function ChartTooltip({ active, payload, label }: TooltipContentProps) {
if (!active || !payload?.length) return null;
return (
<Box
p={2}
b={1}
borderRadius={2}
fontSize={12}
bgColor="white"
color="slate-900"
borderColor="slate-200"
theme={{ dark: { bgColor: 'slate-900', color: 'slate-100', borderColor: 'slate-700' } }}
>
<Box fontWeight={600}>{label}</Box>
{payload.map((entry) => (
<Box key={String(entry.dataKey)}>
{entry.name}: {entry.value}
</Box>
))}
</Box>
);
}
export function SalesChart() {
return (
<ChartContainer
series={['revenue', 'cost']}
vars={{ 'chart-grid': 'slate-200', 'chart-label': 'slate-500' }}
theme={{ dark: { vars: { 'chart-grid': 'slate-800', 'chart-label': 'slate-400' } } }}
height={60}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={months}>
<CartesianGrid stroke="var(--chart-grid)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" stroke="var(--chart-label)" fontSize={12} />
<YAxis stroke="var(--chart-label)" fontSize={12} width={32} />
<Tooltip content={ChartTooltip} />
<Area dataKey="revenue" stroke="var(--color-revenue)" fill="var(--color-revenue)" fillOpacity={0.15} />
<Area dataKey="cost" stroke="var(--color-cost)" fill="var(--color-cost)" fillOpacity={0.15} />
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
);
}The tooltip is the one part that needs a component. Recharts' default tooltip writes background-color: rgb(255, 255, 255) as an inline style, and we measured it staying white under a dark theme. A tooltip drawn as a Box takes theme like anything else on the page.
The trap in the hand-written version
The hand-written version is a stylesheet like this one:
:root {
--chart-1: #0284c7;
--color-revenue: var(--chart-1);
}
.dark {
--chart-1: #7dd3fc;
}When the dark class is on <html>, it works. Put it on a section instead (a dark sidebar, a dark card, a preview), and the revenue line stays the light-mode blue. A custom property is resolved where it is declared and inherited as a finished value: --color-revenue became #0284c7 on :root, and redeclaring --chart-1 further down never reaches it. In our probe the dark section's --chart-1 read #7dd3fc while the line's stroke read rgb(2, 132, 199), a light-mode blue.
ChartContainer declares --color-<series> on itself, below whatever theme is around it, so the alias resolves inside the section that chose the theme. Each theme rule is scoped to the subtree that declared it, so a light chart inside a dark page works the same way.
Colours of your own
A list of series takes the palette in order. A record names each series' paint, as anything fill takes, including a colour with an opacity:
import { ChartContainer } from '@box-kite/react/components/chart';
import { Bar, BarChart, ResponsiveContainer, XAxis } from 'recharts';
const weeks = [
{ week: 'W1', completed: 4, started: 3 },
{ week: 'W2', completed: 6, started: 2 },
{ week: 'W3', completed: 5, started: 4 },
{ week: 'W4', completed: 8, started: 2 },
];
export function Goals() {
return (
<ChartContainer
series={{ completed: 'violet-600', started: 'violet-600/35' }}
theme={{ dark: { vars: { 'color-completed': 'violet-400', 'color-started': 'violet-400/35' } } }}
height={50}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeks}>
<XAxis dataKey="week" stroke="currentColor" fontSize={12} />
<Bar dataKey="completed" stackId="goals" fill="var(--color-completed)" />
<Bar dataKey="started" stackId="goals" fill="var(--color-started)" />
</BarChart>
</ResponsiveContainer>
</ChartContainer>
);
}A named paint is the same in both themes, so the dark value is a theme override, and we measured it winning. A paint the palette does not have ('tael-500') declares nothing. In development it warns, with the nearest token: Did you mean "teal-500"?
The honest limits
- Every series still names its variable. Recharts takes one string per
<Line>or<Area>, sovar(--color-revenue)is written once per series. A variable nobody declared paints nothing: our grid disappeared until--chart-gridexisted. - A canvas chart cannot read a variable.
ctx.fillStyle = 'var(--chart-1)'is ignored (we checked), so a chart that draws on a canvas needs the resolved colour fromgetComputedStyleand a redraw on every theme change. This approach is for SVG charts. - The palette has six slots, and a seventh series wraps round to the first colour.
- Theme rules need
@scope: Chrome 118, Safari 17.4, Firefox 128 and later. Below that, the light values show. - Recharts' own themes are coming. Its experimental provider takes a theme object per chart. Its CSS-variable example uses the same
--chart-1names, declared by hand in a stylesheet. The container is that stylesheet as a prop.
Try it
- Charts: the container, and the small charts that need no library at all.
- Dark mode without the flash: the theme the chart follows.
npm install @box-kite/react recharts, and wrap one chart in<ChartContainer>.