Theming charts
How @pond-ts/charts is styled: one typed theme object, semantic identifiers
that map a series to a style, and — for apps with a design system — a bridge
from CSS custom properties that follows a dark/light toggle.
One styling channel
A chart's entire appearance comes from a single ChartTheme object passed to
ChartContainer. There are no per-component colour / width props — a
<LineChart> doesn't take a color. That's deliberate: a second styling
channel (props and theme) is what bred the styling bugs in
react-timeseries-charts, where the two fought. Here the theme is the one place
style lives.
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
defaultTheme,
} from '@pond-ts/charts';
<ChartContainer width={640} theme={defaultTheme}>
<ChartRow height={220}>
<Layers>
<LineChart series={series} column="price" />
</Layers>
</ChartRow>
</ChartContainer>;
Omit theme and you get defaultTheme. To restyle, hand it a different theme —
never a prop on the mark.
Semantic identifiers: as
A mark's as names what the series is (its role / identity); the theme maps
that identifier to a concrete style. A line resolves theme.line[as] ?? theme.line.default.
<Layers>
<LineChart series={s} column="actual" as="actual" />
<LineChart series={s} column="budget" as="budget" />
</Layers>
const theme = {
...defaultTheme,
line: {
...defaultTheme.line,
actual: { color: '#2563eb', width: 1.5 },
budget: { color: '#e8836b', width: 1.5 },
},
};
The discipline is a handful of roles, not a hue per channel — map many
identifiers onto a few shared styles rather than inventing a colour per series.
as picks the style; the separate axis prop picks which YAxis scale a
mark draws against — two different concerns.
A custom theme
Spread defaultTheme and override the slots you care about — line, band,
area, scatter, box, bar, axis, font, cursor, annotation. A dark
theme is just different values:
import { defaultTheme, type ChartTheme } from '@pond-ts/charts';
const darkTheme: ChartTheme = {
...defaultTheme,
background: '#0f172a',
line: {
...defaultTheme.line,
default: { color: '#93c5fd', width: 1.5 },
},
axis: { label: '#94a3b8', grid: '#1e293b', gridDash: [2, 2] },
cursor: '#94a3b8',
};
Ship your own theme with your app — @pond-ts/charts provides defaultTheme as
a neutral base plus the ChartTheme type; per-brand themes live in your code,
not the library.
Per-series line style: dash
A LineStyle is { color, width, dash? }. The optional dash is a px on/off
pattern — the idiom for setting a modeled series apart from an observed one
(e.g. a GARCH vol estimate dashed under the solid realized line):
line: {
...defaultTheme.line,
realized: { color: '#8b5cf6', width: 1.3 }, // solid — observed
garch: { color: '#d63d8a', width: 1.3, dash: [6, 4] }, // dashed — modeled
forecast: { color: '#5eb5a6', width: 1.3, dash: [2, 3] }, // dotted
}
dash on the series is distinct from a <LineChart gaps="dashed"> — that
dashes a faint bridge over missing data, whereas dash dashes the whole line
as its style. See the Charts/LineChart → LineStyles Storybook story.
Binding to a design system: cssVarTheme
If your app themes from CSS custom properties (--brand-* tokens, a
data-theme dark/light toggle), you don't want to hand-mirror hex into a
ChartTheme. cssVarTheme reads the tokens off the DOM and overlays them on a
base theme — you name only the slots you drive from CSS; everything else falls
through to the base.
import { cssVarTheme, defaultTheme } from '@pond-ts/charts';
const theme = cssVarTheme(defaultTheme, (v) => ({
background: v('--surface'),
line: {
default: { color: v('--brand-primary') },
secondary: { color: v('--brand-secondary') },
},
axis: { label: v('--text-muted'), grid: v('--hairline') },
cursor: v('--text-muted'),
font: { family: v('--font-mono') },
}));
v(name, fallback?) reads the computed custom property. An unresolved var
returns undefined, which keeps the base value — a missing token never blanks
a colour. The result is still the one typed ChartTheme: this generates that
channel from CSS, it doesn't add a second one. With no DOM (SSR, an
Offscreen/worker render) every read returns its fallback, so the call is safe
and yields the base theme.
cssVarTheme reads the DOM once when you call it — don't call it per frame
(getComputedStyle is a layout read). For a chart that should follow a live
toggle, use the hook.
Following a dark/light toggle: useChartTheme
useChartTheme wraps cssVarTheme and re-resolves whenever the theme toggle
flips — a MutationObserver watches the root's data-theme / class, so the
canvas tracks dark/light with the rest of the page:
import { useChartTheme, defaultTheme, ChartContainer } from '@pond-ts/charts';
function PriceChart({ series }) {
const theme = useChartTheme(defaultTheme, (v) => ({
background: v('--surface'),
line: { default: { color: v('--brand-primary') } },
axis: { label: v('--text-muted'), grid: v('--hairline') },
cursor: v('--text-muted'),
}));
return (
<ChartContainer width={640} theme={theme}>
{/* rows … */}
</ChartContainer>
);
}
No mode prop threaded through, no hand-ordered "set the attribute, then read
it" dance. When the resolved theme changes the hook returns a new theme
reference, which is the repaint signal — ChartContainer redraws when handed
a new theme. A watched mutation that doesn't change the resolved values
(e.g. an unrelated class toggle for a modal) returns the same reference, so it
doesn't repaint.
Resolution runs on mount and on watched-attribute changes only — never per
frame — so the getComputedStyle cost stays negligible. base and resolve
are read fresh each resolve, so inline literals are fine (no memoizing needed).
Options. useChartTheme(base, resolve, { target, attributes }):
target— the element whose tokens are read and whose attributes are watched. Defaultdocument.documentElement(<html>); pass a scoped element to theme one subtree.attributes— which attribute changes trigger a re-resolve. Default['data-theme', 'class'](covers adata-themeswitch and Tailwind-styleclass="dark").
The repaint contract
However you build it, the rule is the same: a new theme reference repaints;
the same reference doesn't. useChartTheme handles this for you (new ref only
on a real change). If you compute a theme yourself, memoize it so an unchanged
theme keeps a stable reference and doesn't force redraws.
Notes
- Non-DOM rendering. The CSS-var path is an opt-in DOM adapter over the
typed
ChartTheme, which stays the core contract — so a future OffscreenCanvas / worker renderer (nogetComputedStyle) still themes via a plainChartTheme. - Bring your own theme object. Build on
defaultTheme(the neutral base) + theChartThemetype; keep per-brand themes in your app (or your design system's package), assembled from your tokens viacssVarTheme.
See also
- Resizable multi-panel layout — a full chart layout the theme applies to.
Charts/Theming → CssVarsandCharts/LineChart → LineStylesStorybook stories — the live dark/light toggle and the dash patterns.