Skip to content

Theming ​

Want the chart's colors to follow your app's dark/light theme? Set CSS variables, and subscribe to the swap. The library ships no theme presets — in the browser you set CSS variables on the container, and where there is no CSS (server-side PNG, worker, tests) you hand over the same keys by injecting a StyleReader.

The subscription is not optional. New values reach the DOM parts the moment the cascade changes, but a canvas repaints only when asked — observeTheme is the one line that asks (below).

css
.chart {
  --chart-candle-up: #16a34a;
  --chart-candle-down: #dc2626;
  --chart-grid: #e2e8f0;
  --chart-crosshair: #94a3b8;
}

The priority is config override > CSS variable > default. With no value it falls to the default. One kind of value sits outside the three tiers: a histogram point's own color is a literal the producer chose, so that bar has stepped out of the theme on purpose. A point's tone is different — it does not name a colour, it picks a slot (--chart-histogram-up / -down) and stays inside the theme. What happens to a value that can't be read depends on where it is drawn:

Canvas (candles, lines, grid, crosshair…)DOM (axis labels, tooltip, legend)
Number slotsBare numbers and px only — 0.5rem and 60% fall to the default—
Color slotsIf the browser rejects it, that element isn't drawnThe browser resolves var(), so plain CSS rules apply
light-dark(…), color-mix(…)The canvas can't read them — nothing is drawnThey work
Typo (nope)Nothing is drawnDepends on the kind of property — below

There are actually three paths. The crosshair badge and the price-line label live in the DOM, but they arrive as a resolved value, not as var() — validity follows DOM rules (light-dark() works) while the update waits on requestRender(), like the canvas. This one inherited a trait from each of the two columns above, which say opposite things — so if you read only the table above and file the badge under canvas, you diagnose it backwards.

ValidityUpdate
Canvas (candles, lines, grid…)If the canvas rejects it, nothing is drawnrequestRender()
DOM + var() (axis labels, tooltip, legend)Plain CSS rules applyImmediate
DOM + resolved value (badge, price-line label)Plain CSS rules applyrequestRender()

A typo on the DOM side won't fit on one line. Of all six DOM leaves, the sentence was true of only four:

Property the DOM leaf declaresWith a typo in it
Color, font size (inherited properties)You see the value inherited from the page
BackgroundNot inherited but the initial value — it goes transparent. The tooltip background vanishes whole and light text floats over the chart
Familynope is a valid name, so nothing is invalidated — there is no such font, so it draws in the browser's default font

That's why light-dark() leaves the tooltip and legend fine while only the grid and candles disappear. To put one value on both paths, pick a syntax the canvas reads too — #rrggbb, rgb().

The canvas side reads "nothing is drawn" because it is the fix for inheriting the color of whatever was drawn just before and painting in the wrong color — e2e/specs/canvas.spec.ts holds it with real Chromium pixels.

The variables it reads ​

style-vars.test.ts holds this page against the source in both directions — a variable this page (or any demo) names that nothing reads, and a variable the source declares that this page never mentions. The table's rows themselves are not parsed; the page is read as one bag of names. To land a typo you would have to make the same mistake in both files.

VariableTarget
--chart-line, --chart-line-width, --chart-line-dashline
--chart-point, --chart-point-radiusdata point
--chart-candle-up, --chart-candle-down, --chart-candle-wick-width, --chart-candle-body-ratiocandle
--chart-bar-up, --chart-bar-downcolor of the OHLC bar
--chart-bar-line-widthstroke width of the OHLC bar (px) — the vertical line and the ticks
--chart-bar-tick-ratiohorizontal width of the OHLC bar — tick length against slot width (0..1)
--chart-area, --chart-area-bottom, --chart-area-line, --chart-area-line-width, --chart-area-line-dasharea (give --chart-area-bottom and you get a top→bottom vertical gradient)
--chart-baseline-top, --chart-baseline-bottombaseline's upper/lower line color (above/below the baseline value)
--chart-baseline-top-fill, --chart-baseline-bottom-fillbaseline's upper/lower fill color
--chart-baseline-line-widthstroke width (px) of baseline's upper/lower data line — not the baseline itself. The line set by the baseline option is not drawn
--chart-histogram, --chart-histogram-up, --chart-histogram-down, --chart-histogram-bar-ratiohistogram — a bar that carries a tone wears -up / -down, a bar without one wears --chart-histogram
--chart-grid, --chart-grid-width, --chart-grid-dashgrid
--chart-pane-divider, --chart-pane-divider-widthpane border (when there is more than one pane)
--chart-crosshair, --chart-crosshair-width, --chart-crosshair-dashcrosshair line
--chart-crosshair-badge, --chart-crosshair-badge-backcrosshair axis badge
--chart-price-line, --chart-price-line-width, --chart-price-line-dash, --chart-marker, --chart-watermark, --chart-spanstandard decorations (a price line's badge text is black or white, whichever reads better, when the line colour is #rgb, #rrggbb, or rgb()/rgba() with integer channels 0–255 and alpha omitted or exactly 1; white on anything else, percentages and fractional channels included)
--chart-label, --chart-label-font-size, --chart-label-font-familyaxis label
--chart-tooltip, --chart-tooltip-backtooltip (DOM)
--chart-legendlegend (DOM)
--chart-bandband and channel fill (@finchart/indicators)
--chart-profile, --chart-profile-pocVolume Profile bars and POC (@finchart/indicators)
--chart-kagi-up-width, --chart-kagi-down-widthstroke width (px) of a Kagi line's yang (thick) and yin (thin) strokes — its colours are --chart-candle-up / --chart-candle-down (@finchart/indicators)
--chart-pnf-widthstroke width (px) of a Point & Figure chart's X's and O's — its colours are --chart-candle-up / --chart-candle-down (@finchart/indicators)
--chart-drawing, --chart-drawing-width, --chart-drawing-dash, --chart-drawing-labeldrawing tools (@finchart/tools) — the label is the text on a measure's box, the box wears --chart-drawing

Kinds of value — the name says it ​

SuffixMeaningExample
-width · -radiuspx scalar. A bare number or px--chart-line-width: 2 · 2px
-ratioUnitless 0..1. Out-of-range values get clamped--chart-candle-body-ratio: 0.6
-dashCSS dash list. Negatives are ignored--chart-grid-dash: 4,4
-font-size · -font-familyA CSS value verbatim. The unit is required--chart-label-font-size: 12px
everything elsecolor--chart-candle-up: #16a34a

Only -font-size requires a unit, because that value goes into the ctx.font shorthand as it is — 12 is not valid as a CSS font, so it falls to the default. Widths and radii take a bare number because canvas coordinates are already px.

The defaults are not written here. Copy 47 of them by hand into a table and that table goes stale at once, and unlike the other tables in this document no machine holds it (most of the specs are not on the public surface). The defaults for the six series, the chart, and the axis label are printable at runtime — DEFAULT_LINE_STYLE·DEFAULT_AREA_STYLE·DEFAULT_BAR_STYLE· DEFAULT_BASELINE_STYLE·DEFAULT_CANDLE_STYLE·DEFAULT_HISTOGRAM_STYLE· DEFAULT_PLOT_STYLE·AXIS_LABEL_SPEC are all public. No need to open the source.

The extension packages are the same — VOLUME_PROFILE_STYLE_SPEC· BAND_STYLE_SPEC·KAGI_STYLE_SPEC·POINT_AND_FIGURE_STYLE_SPEC in @finchart/indicators, DRAWING_STYLE_SPEC in @finchart/tools. Invent a value and nobody in code review catches that a stroke width shifted a little. The gap that remains is on the decoration side (crosshair, badge, price line, marker, watermark, span, tooltip, legend), and whether to publish those specs is in the post-release queue.

Only what lives in the DOM overlay is the exception — axis labels, tooltip, and legend pass var() through and let the browser interpret it. So their colors follow a changed CSS variable without a re-render, while the things drawn on the canvas (candles, lines, grid…) land on the next render — that's why the dark-switch recipe below needs its second line (requestRender()).

Fonts take size and family separately. The family defaults to inherit, so set nothing and it follows the font of the page the chart is mounted on — the text living in the DOM (legend, tooltip, DOM axis labels) as much as the text drawn on the canvas (canvas axis labels, marker captions, Fibonacci levels).

The canvas's ctx.font knows nothing of inherit. So on the canvas side the core's labelFontFamily(readStyle) realizes inherit by resolving in the order variable → the container's computed font-family → sans-serif. Custom series and decorations use it too when they draw text — skip it and only that text won't follow the page font.

DOM hook — the pane divider handle ​

The border between panes is drawn by the canvas (--chart-pane-divider, table above). The sign that it can be dragged is a separate thing — a transparent handle in the overlay carries the [data-chart-divider] attribute, and [data-dragging] is added while dragging. Put the state styling on :hover alone and it flickers every time the pointer leaves the handle mid-drag, so the recipe is to write both together:

css
.my-chart [data-chart-divider]:hover,
.my-chart [data-chart-divider][data-dragging],
.my-chart [data-chart-divider]:focus-visible {
  background: rgba(41, 98, 255, 0.22);
}
.my-chart [data-chart-divider]:focus-visible {
  outline: 2px solid #2962ff;
}

The handle is also a keyboard control — a focusable role="separator" with aria-valuenow/aria-valuemin/aria-valuemax giving the upper pane's height and limits, moved by the arrow keys (see the key table in plot-contract). The library draws no focus style of its own — the browser's default outline on a transparent 7px strip is easy to miss, so give :focus-visible a look, as the third selector above does.

Proof: the vanilla demo (apps/demo-vanilla/src/style.css) is exactly this recipe.

Theming without CSS — headless ​

There is no CSS in the core. Styles are read through a StyleReader — (name) => string — and the browser wiring (browserDeps) merely plugs into that slot an implementation that reads computed style (cssReader). An environment without CSS plugs anything it likes into the same slot. The keys are the --chart-* from the table above, unchanged — one name carries across both worlds.

ts
import { createPlotModel } from "@finchart/core";
import type { StyleVarName } from "@finchart/core";
import type { ShellStyleVarName } from "@finchart/dom";

// Typing the keys is what turns a misspelt variable from a value that
// silently falls back into a compile error naming the real one.
// `StyleVarName` covers everything the core draws; the shell's legend and
// tooltip add `ShellStyleVarName`, and the indicators' and drawing tools'
// names derive from their public specs with `StyleVarNamesOf`.
const dark: Partial<Record<StyleVarName | ShellStyleVarName, string>> = {
  "--chart-candle-up": "#22c55e",
  "--chart-candle-down": "#f87171",
  "--chart-grid": "#1e293b",
  // px scalars (-width, -radius) take a bare number too — canvas coordinates are already px.
  "--chart-line-width": "2",
  // **`-font-size` requires a unit** — this value goes into the `ctx.font`
  // shorthand as it is. `"12"` is not valid as a CSS font, so the canvas
  // silently rejects it.
  "--chart-label-font-size": "12px",
};

const model = createPlotModel({
  size: { width: 800, height: 400 },
  series: { series: candleSeries(), data },
  deps: { createStyleReader: () => (name) => dark[name] ?? "" },
});

Careful: the generalization "numbers go in as strings too — the core does the parsing" is false for -font-size. Write "12" and the core's applyFont builds "12 sans-serif", and the canvas rejects it. The rules per kind are entirely in the Kinds of value table above, and this section follows that table too — writing it as a string does not mean any string will do.

An empty string is "no value" — that leaf falls to the default. To dress one chart differently, the registration's options override comes before the reader (rank 1 of the priority order).

Proof: the Worker rendering example wears dark by exactly this recipe — the whole chart lives in a worker so there is no CSS, and the theme is a JS object in apps/examples/src/cases/worker-render.worker.ts. Workers covers the rest of that wiring.

Do not take this road in the browser. browserDeps does accept createStyleReader, but the DOM overlay (axis labels, tooltip, legend) has the browser interpret var(), so it never rides the injected reader — the colors of canvas and overlay split apart. In the browser, CSS variables are the answer; injection is for where there is no CSS.

Dark palette reference ​

Dark ends at swapping the variables — the core reads them every frame, so toggling a class and one requestRender() re-dresses everything down to grid, labels, badge, tooltip, watermark, and drawings — except a drawing you styled by hand: a per-drawing style is a saved literal, so it deliberately keeps its color across the switch (absent style follows the theme).

ts
document.body.classList.toggle("dark");
plot.requestRender();

With observeTheme subscribed, the second line is already taken care of.

Following the app's theme ​

Swap the variables and the chart changes only halfway — axis labels, tooltip, and legend follow at once while candles, grid, and crosshair stay in the old colors. The values have already arrived; nobody is there to redraw the canvas (third column of the table above).

observeTheme is that somebody. It watches both ways a value can move — the OS switching prefers-color-scheme, and a class / data-theme / inline style changing on the container or any ancestor:

ts
import { observeTheme } from "@finchart/dom";

const stop = observeTheme(container, () => plot.requestRender());

In React it is a prop — <ChartContainer followTheme> makes that call for the container element and stops it on unmount; pass { attributes: [...] } to watch a different attribute list.

It is deliberately not wired by browserDeps: a page with one fixed palette should not carry a MutationObserver it never uses. Import it when a theme can actually change.

Two things stay out of reach, both by design — a theme applied by restructuring the DOM above the chart rather than re-dressing it, and a swapped stylesheet. Call plot.requestRender() yourself on those.

Color vision ​

The palette below is a starting point whose contrast was verified against a #0b1220 background (the value the dogfooding screen uses — the background is the app's, not one of our tokens, so it is only recorded here). Put it on a white card and --chart-label lands at 2.56, short of AA, and then you have to pick the colors again. Only the colors change — widths, ratios, and dashes are not the theme's.

Color vision is not verified. Up/down is this library's primary encoding, for the candle, bar, baseline and histogram that encoding is nothing but color, and both the defaults and the palette below are red-green. Two of the price-axis transforms in @finchart/indicators carry a second channel — the Kagi line is thick for yang and thin for yin (--chart-kagi-up-width / --chart-kagi-down-width), and a Point & Figure column is X's or O's — so they read without color (until a box's cell is too small for a glyph and the run becomes a bar); their colors are the candle's, so the table below is their table. WCAG contrast after a deuteranopia simulation (Viénot 1999):

Paletteup / downnormaldeuteranopia
core default#16a34a / #dc26261.471.16
the dark below#22c55e / #f871711.211.01

1.01 means the same color. Switch to blue/orange and they separate:

css
.dark .my-chart {
  --chart-candle-up: #2563eb;   --chart-candle-down: #ea580c;
  --chart-bar-up: #2563eb;      --chart-bar-down: #ea580c;
  --chart-baseline-top: #2563eb; --chart-baseline-bottom: #ea580c;
  --chart-histogram-up: #2563eb; --chart-histogram-down: #ea580c;
}

There is one reason we don't move the defaults to this — the values belong to the consumer, and moving the defaults breaks apps already tuned to our colors.

css
.dark .my-chart {
  /* background elements */
  --chart-grid: #1e293b;
  --chart-pane-divider: #334155;
  --chart-crosshair: #475569;
  --chart-label: #94a3b8;
  --chart-watermark: rgba(148, 163, 184, 0.1);
  --chart-span: rgba(148, 163, 184, 0.12);

  /* series */
  --chart-line: #60a5fa;
  --chart-point: #60a5fa;
  --chart-area: rgba(96, 165, 250, 0.18);
  --chart-area-line: #60a5fa;
  --chart-candle-up: #22c55e;
  --chart-candle-down: #f87171;
  --chart-bar-up: #22c55e;
  --chart-bar-down: #f87171;
  --chart-baseline-top: #34d399;
  --chart-baseline-bottom: #f87171;
  --chart-baseline-top-fill: rgba(52, 211, 153, 0.15);
  --chart-baseline-bottom-fill: rgba(248, 113, 113, 0.15);
  --chart-histogram: rgba(100, 116, 139, 0.5);
  --chart-histogram-up: #22c55e;
  --chart-histogram-down: #f87171;
  --chart-area-bottom: rgba(96, 165, 250, 0.02);

  /* decorations — badge and tooltip are an inverted pair of back and text */
  --chart-crosshair-badge: #0f172a;
  --chart-crosshair-badge-back: #94a3b8;
  --chart-tooltip: #0f172a;
  --chart-tooltip-back: rgba(226, 232, 240, 0.92);
  --chart-legend: #cbd5e1;
  --chart-price-line: #f59e0b;
  --chart-marker: #e2e8f0;

  /* extension packages */
  --chart-band: rgba(96, 165, 250, 0.12);       /* @finchart/indicators */
  --chart-profile: rgba(148, 163, 184, 0.22);  /* @finchart/indicators */
  --chart-profile-poc: rgba(251, 191, 36, 0.5);/* @finchart/indicators */
  --chart-drawing: #818cf8;                 /* @finchart/tools */
  --chart-drawing-label: #0f172a;           /* @finchart/tools — text on a measure's box */
}

The values the dogfooding screen (apps/examples/src/theme.css) carries have been through real use. The rest (baseline, area, profile, and so on) have not been on a screen yet. Going forward the apps/examples/src/trading.ts screen switches to dark by this road and verifies these values.

Histogram up and down ​

A histogram bar can carry a tone — "up" or "down" — and the theme picks the colour: --chart-histogram-up and --chart-histogram-down, which fall back to the candle's colours. What counts as up is the producer's to say: a volume bar follows its candle, an indicator's bar follows the bar before it. A bar without a tone wears --chart-histogram, and a bar with one never reads it — an app that themed only --chart-histogram should set the two slots as well.

Resolution follows CSS specificity: the most local explicit value wins. point.color, then a slot override (style: { up, down }), then one explicit series colour (style: { color } — a consumer who asked for one colour keeps it, toned input or not), then the slot's variable. The plain --chart-histogram is the last step only for a bar without a tone.

The two variables are read from the chart's container, so they are chart-wide — every histogram in the chart, a volume pane and a MACD pane alike, wears them. To let one pane recede (volume under price is the classic case) while the indicator bars stay solid, give that series its own pair: histogramSeries({ style: { up, down } }) beats the variables for that series only.

To make the histogram speak the candle's language under your own palette, alias the slot instead of copying the value — a custom property may hold var(), the reader sees the computed result:

css
.my-chart {
  --chart-histogram-up: var(--chart-candle-up);
  --chart-histogram-down: var(--chart-candle-down);
}

Two things a toned series does not do: registered without a color, it shows no swatch in the legend or tooltip (a two-colour series has no one colour to show — read it by name and value), and the fallback of a leaf never holds var() — the canvas can't read it.

Styling your own extension ​

A custom series or indicator joins the same three-tier system the built-in ones use — declare a spec, resolve it at draw time. This is not a special path: @finchart/indicators is written exactly this way, with zero special access to the core.

ts
import { resolveStyle, styleSpec } from "@finchart/core";
import type { StyleSpec, StyleVarNamesOf } from "@finchart/core";

interface RibbonStyle {
  fill: string;
  width: number;
}

export const RIBBON_STYLE_SPEC = /* @__PURE__ */ styleSpec({
  fill: { css: "--acme-ribbon", fallback: "rgba(148, 163, 184, 0.2)" },
  width: { css: "--acme-ribbon-width", fallback: 1 },
}) satisfies StyleSpec<RibbonStyle>;

// At draw time — context is the SeriesContext your draw() receives:
const style = resolveStyle(RIBBON_STYLE_SPEC, context.readStyle, overrides);

context.readStyle is the same reader the built-in series get, built once per render — in the browser it reads computed style, headless it reads whatever the consumer injected, and your spec cannot tell the difference. Override > variable > fallback applies unchanged, and so does the range clamp on numeric leaves.

Your names become types the same way ours do:

ts
type RibbonVar = StyleVarNamesOf<typeof RIBBON_STYLE_SPEC>;
// a theme covering the chart and your extension:
type ThemeVar = StyleVarName | RibbonVar;

Pick your own prefix (--acme-* above) — the reader takes any name, and --chart-* is a convention, not a filter. Reusing a --chart-* name is allowed and does something useful: your extension inherits whatever the consumer already set for that variable. Do it only on purpose, and keep the fallback identical to the one the chart declares — the same name falling back differently in two places means a consumer who sets nothing sees two values for one variable.