Theming
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.
Choosing an entry point
There are three ways to supply a theme. Pick by how your app manages colour:
| You have… | Use | How |
|---|---|---|
| A fixed palette / no design-system tokens | a literal ChartTheme | Spread defaultTheme, override slots, pass theme={…}. |
| CSS custom properties, but a static read is enough | cssVarTheme | cssVarTheme(base, v => ({ … })) reads tokens off the DOM once. |
| CSS tokens and a runtime dark/light toggle | useChartTheme | The hook re-resolves on a data-theme / class flip. |
All three produce the same thing — one typed ChartTheme object — and all obey
the same repaint contract. The docs site itself runs on
useChartTheme (see useSiteChartTheme),
so every live chart on this site re-themes when you flip the theme toggle above.
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.
The ChartTheme slots
A ChartTheme has one role-keyed slot per draw layer (the as lookup) plus
a set of fixed slots for chrome. Each layer slot resolves slot[as] ?? slot.default,
so a chart always renders even for an unnamed identifier.
| Slot | Role-keyed? | Style shape |
|---|---|---|
line | yes | { color, width, dash? } |
area | yes | { color, width, fill, fillOpacity } (fill must be CSS hex) |
band | yes | { fill, opacity } |
scatter | yes | { color, radius, outline, outlineWidth, selectedOutline, selectedWidth, label } |
bar | yes | { fill, opacity, highlight, gap, minWidth, outlineWidth, hover?, dimmed?, emphasisOpacity?, selectedOutline?, bands? } |
box | yes | { fill, fillOpacity, stroke, strokeWidth, median, medianWidth, whisker, whiskerWidth } |
candle | yes | { rising:{body,wick}, falling:{body,wick}, neutral?:{…}, bodyWidth?, wickWidth } — a direction pair, not a single colour |
axis | no | { label, grid, gridDash, sessionDivider?, band?, title? } |
font | no | { family, size } |
background? / cursor? / chip? / gap? / annotation? | no | string / string / { background } / { connectorOpacity } / { color, fillOpacity, depth:[n,n,n] } |
brush? | no | { fill, edge? } — the drag band (see below) |
Two built-ins ship: defaultTheme (a neutral light base) and
estelaTheme (@estela/ui's palette on a dark ground — the "restyle by
swapping the object" proof). Override only the slots you care about; the rest
falls through to the base.
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.
Interaction states, and the channel rule
Marks carry an interaction state — at rest, under the pointer, selected, or outside somebody else's selection. One rule decides how each mark shows it:
:::tip The channel rule State may only use a channel the mark isn't already using for data. :::
That single rule produces a different answer per mark, and each answer is forced rather than chosen:
| Mark | State channel | Because the mark already spends… |
|---|---|---|
| Bar | fill — a hue swap | position/length on the value |
| Candle | weight + alpha | colour on direction (up/down) |
| Line | weight (+ opacity to recede) | colour on identity — which series |
| Area | fill strength + edge weight | colour on identity, fill on the shape |
| List row | band + rail | the glyph on data |
The line row is the one to internalise: a line's colour is how a reader tells one series from another, so recolouring a selected line destroys the thing selection is meant to help you read. A selected trace thickens and keeps its hue; the others recede in opacity with their hue intact.
Bar states
A bar is the mark with the fullest ladder — four states cannot be read as four
shades of one colour, so defaultTheme's bar slot is a small palette in which
state is a hue difference:
| State | BarStyle field | defaultTheme value | Reads as |
|---|---|---|---|
| Rest | fill (at opacity) | #2A9D8F, opacity: 1 | teal — the resting data colour |
| Hover | hover | #3FBFAE | a brighter teal, never blue |
| Selected | highlight (+ the outline) | #3F5BE0 | blue — the committed state |
| Dimmed | dimmed | rgba(42,157,143,0.32) | the resting teal, receded |
| Drag band | theme.brush | rgba(63,91,224,0.07) + a rgba(63,91,224,0.45) 1px edge | the selection blue, in flight |
The reservation is the point: blue means committed selection, so hover is a brighter teal rather than a pale blue — otherwise a passing pointer and a real selection read as the same act. The drag band is that same blue at 7% for the mirror-image reason: a live sweep is a selection being made, so it should be the selection's hue before it commits.
Precedence when a bar is in more than one state: **selected > hovered > dimmed
rest**.
emphasisOpacity(default1) is the alpha a live bar pops to; with a restingopacityof1the default palette carries all of its emphasis in hue, and none in alpha.
hover, dimmed, selectedOutline and emphasisOpacity are all optional.
A theme that sets no dimmed dims nothing (nothing is auto-dimmed — the
library renders the state, the theme supplies the colour), and a theme with no
hover falls back to highlight, which is the pre-palette two-step. So a
hand-built theme keeps its own behaviour; only defaultTheme opts in to the
full ladder.
const theme: ChartTheme = {
...defaultTheme,
bar: {
...defaultTheme.bar,
default: {
...defaultTheme.bar.default,
fill: '#4a7fb5',
hover: '#6fa3d6',
highlight: '#d4753a',
dimmed: 'rgba(74,127,181,0.3)',
},
},
brush: { fill: 'rgba(212,117,58,0.08)', edge: 'rgba(212,117,58,0.45)' },
};
brush styles the shared drag band — the live region <RangeCursor> and
<MultiSelector> both paint while a drag is in flight (one renderer, so the
two can never drift). Omit it and the band falls back to the cursor ink at
0.12 with no edges.
Trace states: line and area
Because a trace spends colour on identity, its state tokens are about weight and recession — with one exception, which is the interesting part.
| Token | On | defaultTheme | Is |
|---|---|---|---|
selectedWidth | line area | 3 | Stroke width when the series is selected |
hoverWidth | line | — | The transient echo of selectedWidth |
dimmedOpacity | line area | 0.32 | Alpha for series outside the selection |
selectedFillOpacity | area | 0.55 | Fill strength when selected — an area's mark is its fill |
spanColor | line area | #3F5BE0 | Hue for a swept window inside one series |
spanColor is the exception to the channel rule, and it earns it. A
whole-series selection cannot take a hue, because hue is which series it is. But
a window inside one series can: identity is not in question there, so the
covered stretch takes the selection blue — the same bar.highlight value, so a
swept region on a trace reads as the same act as the band that made it.
It applies only when a single trace is swept. With two, both would go blue and you'd lose identity in exactly the place you're looking. See sweeps & multi-select.
Every one of these is optional, and a theme that omits them renders no state — the library draws the state, the theme supplies the colour. So a hand-built theme keeps its current look until you opt in.
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
- Layout · Resizable multi-panel layout — chart layouts the theme applies to.
useSiteChartTheme— this site's liveuseChartThemebridge (the dogfood).- Storybook:
Theming → CssVars(the live dark/light toggle) andCharts/LineChart → LineStyles(thedashpatterns). - The API reference — the full
ChartThemetype and every style interface.