Skip to main content

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…UseHow
A fixed palette / no design-system tokensa literal ChartThemeSpread defaultTheme, override slots, pass theme={…}.
CSS custom properties, but a static read is enoughcssVarThemecssVarTheme(base, v => ({ … })) reads tokens off the DOM once.
CSS tokens and a runtime dark/light toggleuseChartThemeThe 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.

SlotRole-keyed?Style shape
lineyes{ color, width, dash? }
areayes{ color, width, fill, fillOpacity } (fill must be CSS hex)
bandyes{ fill, opacity }
scatteryes{ color, radius, outline, outlineWidth, selectedOutline, selectedWidth, label }
baryes{ fill, opacity, highlight, gap, minWidth, outlineWidth, hover?, dimmed?, emphasisOpacity?, selectedOutline?, bands? }
boxyes{ fill, fillOpacity, stroke, strokeWidth, median, medianWidth, whisker, whiskerWidth }
candleyes{ rising:{body,wick}, falling:{body,wick}, neutral?:{…}, bodyWidth?, wickWidth } — a direction pair, not a single colour
axisno{ label, grid, gridDash, sessionDivider?, band?, title? }
fontno{ family, size }
background? / cursor? / chip? / gap? / annotation?nostring / 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:

MarkState channelBecause the mark already spends…
Barfill — a hue swapposition/length on the value
Candleweight + alphacolour on direction (up/down)
Lineweight (+ opacity to recede)colour on identity — which series
Areafill strength + edge weightcolour on identity, fill on the shape
List rowband + railthe 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:

StateBarStyle fielddefaultTheme valueReads as
Restfill (at opacity)#2A9D8F, opacity: 1teal — the resting data colour
Hoverhover#3FBFAEa brighter teal, never blue
Selectedhighlight (+ the outline)#3F5BE0blue — the committed state
Dimmeddimmedrgba(42,157,143,0.32)the resting teal, receded
Drag bandtheme.brushrgba(63,91,224,0.07) + a rgba(63,91,224,0.45) 1px edgethe 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 (default 1) is the alpha a live bar pops to; with a resting opacity of 1 the 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.

TokenOndefaultThemeIs
selectedWidthline area3Stroke width when the series is selected
hoverWidthlineThe transient echo of selectedWidth
dimmedOpacityline area0.32Alpha for series outside the selection
selectedFillOpacityarea0.55Fill strength when selected — an area's mark is its fill
spanColorline area#3F5BE0Hue 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. Default document.documentElement (<html>); pass a scoped element to theme one subtree.
  • attributes — which attribute changes trigger a re-resolve. Default ['data-theme', 'class'] (covers a data-theme switch and Tailwind-style class="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 (no getComputedStyle) still themes via a plain ChartTheme.
  • Bring your own theme object. Build on defaultTheme (the neutral base) + the ChartTheme type; keep per-brand themes in your app (or your design system's package), assembled from your tokens via cssVarTheme.

See also