ox-charts

Theming and adding a theme

One OxTheme object colours the chrome, the shadcn components and the canvas.

An OxTheme is a flat object of tokens: bg, surface, border, text, textMuted, accent, buy, sell, the candle palette and a dark flag. ChartShell writes each token as a --ox-* CSS variable on its root, and pushes the same values into the canvas, which can't read CSS.

TokenCSS variableAlso feeds
surface--ox-surfaceshadcn --popover, --card
accent--ox-accentshadcn --primary, --ring
textMuted--ox-text-mutedshadcn --muted-foreground
sell--ox-sellshadcn --destructive
upColor / downColor--ox-up-color / --ox-down-colorCandles on the canvas

Try it

Loading live chart…
Live · switch mode and colour theme; the code line shows the call

Adding a theme

import { createTheme, BUILTIN_THEMES, ChartShell } from "@option-x-tech/ox-charts";

// Derive one from a built-in base...
const acme = createTheme("dark", { name: "acme", accent: "#ff6a00", upColor: "#3ddc84" });

<ChartShell themes={[acme, ...BUILTIN_THEMES]} theme="acme" /* ... */ />
  • Keep every colour a 6-digit hex or rgba(): the same values fill canvas pixels.
  • Set dark correctly. It picks color-scheme, the dark: variant and the mode the theme belongs to.
  • A candle palette alone is a ColorTheme. It overlays up/down colours and buy/sell onto whichever base is active.
  • Adding a built-in? Add it to theme-contrast.test.ts, which enforces WCAG contrast.

Popups follow the shell

Dialogs and menus portal into the shell's own host, so two terminals on one page can wear different themes and popups stay visible in fullscreen.

Built-in candle colour themes

Generated from COLOR_THEMES in src/ui/theme.ts.

ThemeNameUp / down on darkUp / down on light
OptionXoptionx#74d10a#ff4d5e#619920#af1b3c
Terminalterminal#58d8ae#e95048#259c79#b11b1d
Voltagevoltage#37e167#f72a71#24a047#ad1a4d
Meridianmeridian#86c5fa#c57721#228dd6#895113
Ultravioletultraviolet#c8aefa#a68921#9769dc#735e14
Kilnkiln#f3b030#d86162#ad7c1f#a43237
Harbourharbour#37d6dd#8979e6#24979d#5f4bb4
Sagesage#8ecfa9#bb7582#579672#8b4957
Graphitegraphite#ededf0#6e6e78#8e8e96#3b3b44
Daliandalian#faa493#218c8e#e85339#146364

Reference: how CSS and themes work

One OxTheme object drives everything that has a colour: the shell chrome (toolbars, menus, dialogs), the shadcn/ui components, and the canvas engine. Nothing is themed per component and there is no per-theme CSS.

OxTheme (src/ui/theme.ts)
  ├─ applyThemeVars()  → --ox-* CSS variables on the shell root
  │                        └─ .ox-ui bridge → shadcn variables (--background, --primary …)
  │                              └─ Tailwind utilities (ox:bg-popover, ox:text-primary …)
  └─ chartColorsOf() / orderflowColorsOf() / tradingPaletteOf()
                        → hex colours pushed into the canvas engine

1. How CSS and themes work

Tokens → CSS variables

OxTheme (src/ui/types.ts) is a flat object of tokens: bg, surface, surfaceHover, border, text, textMuted, accent, buy, sell, the chart colours, the candle palette, shadow, radius and font. It also has a dark flag.

ChartShell resolves the active theme from its base (light/dark mode), the chosen base theme and the colour theme (applyColorTheme). It then calls applyThemeVars(root, theme) on its root element ([data-ox-shell]). That call does three things:

  • It writes every string token as --ox-<kebab-name>, e.g. surfaceHover becomes --ox-surface-hover.
  • It sets color-scheme to dark or light.
  • It toggles data-ox-dark. The Tailwind dark: variant keys off this attribute.

--ox-* is the only source of truth. Hand-written chrome styles read it directly (style={{ background: "var(--ox-surface)" }} and the sheet in src/ui/components/styles.ts).

The shadcn bridge

The shadcn/ui components (Base UI flavour, "Mira" preset, in src/ui/shadcn/) are styled with shadcn's semantic variables. src/ui/styles/ox-ui.css maps each of them onto an ox token, once, scoped to .ox-ui:

shadcn variableox token
--background--ox-bg
--foreground--ox-text
--card, --popover--ox-surface
--primary, --ring--ox-accent
--secondary, --muted, --accent--ox-surface-hover
--muted-foreground--ox-text-muted
--destructive--ox-sell
--border, --input--ox-border
--radius--ox-radius

The fallbacks in the bridge are the dark theme's values, used only by chrome rendered outside a shell. Because the bridge is made of var() aliases, every theme works as soon as its --ox-* values are on the root. That covers the built-in bases, all colour themes, light and dark, and anything made with createTheme().

Tailwind

@theme inline points Tailwind's colour and radius scales at the bridge variables: ox:bg-popover compiles to background-color: var(--popover), and ox:rounded-lg to border-radius: var(--radius). Popup surfaces also use the tokens directly where shadcn has no equivalent: ox:[box-shadow:var(--ox-shadow)] and ox:[font:var(--ox-font)].

This is a library that ships inside host apps, so the CSS is kept contained:

  • No preflight. Only Tailwind's theme and utilities are compiled. A zero-specificity reset in ox-ui.css covers shadcn parts ([data-slot]) inside .ox-ui only.
  • Prefixed utilities. Every class is ox:-prefixed, so only prefixed candidates are generated and a host's own Tailwind never collides.
  • Nothing on :root. The build moves Tailwind's variable declarations from :root to .ox-ui, and renames the ones it declares from --ox-* to --oxtw-*, so they can never shadow a token.
  • Unlayered utilities. A host's unlayered element rules cannot outrank them.

The CSS is compiled ahead of time, so hosts need no Tailwind:

node tools/build-ui-css.mjs          # regenerate src/ui/styles/ox-ui.generated.ts
node tools/build-ui-css.mjs --check  # exit 1 if stale

ensureUiStyles() injects the result as a <style> tag on first use. Re-run the build after adding or changing an ox: class; src/ui/styles/ui-css.test.ts fails while the committed output is stale.

Popups and portals

ChartShell renders an OxPortalHost inside its root. Every dialog, menu, popover, select and tooltip portals into that host, so it:

  • inherits that shell's --ox-* values, which keeps two charts on one page with different themes separate;
  • stays visible when the shell root is fullscreen;
  • resolves the bridge, because the host carries .ox-ui.

Nested popups (a Select in a dialog, a colour popover in the drawing settings) mount in the same host. Base UI handles their stacking and dismissal.

Menus, popovers and selects are modal. An outside press lands on Base UI's transparent backdrop, which ox-ui.css lifts to the popover z-tier. The press that closes a popup therefore never reaches the chart canvas, whether it comes from a mouse, touch or pen.

2. Adding a theme

Add an OxTheme object. Nothing else is needed: no CSS, no component changes.

import { createTheme } from "@option-x-tech/ox-charts";

// Either derive one from a built-in…
const acme = createTheme("dark", { name: "acme", accent: "#ff6a00", upColor: "#3ddc84" });

// …or write a full OxTheme literal next to the built-ins in src/ui/theme.ts
// and list it in BUILTIN_THEMES / BASE_THEMES.

<ChartShell themes={[acme, ...BUILTIN_THEMES]} theme="acme" … />

Keep every colour a 6-digit hex or rgba(), because the same values feed canvas fills. Set dark correctly: it picks the color-scheme, the dark: variant and the light/dark mode the theme belongs to. theme-contrast.test.ts enforces WCAG contrast for the built-ins; add a new built-in there as well.

A candle palette alone is a ColorTheme (see COLOR_THEMES). It overlays upColor, downColor, the wicks and buy/sell onto whichever base is active.

3. Canvas colours come from the same tokens

The engine draws on <canvas>, where CSS variables cannot resolve, so it gets the same theme as plain colour values:

  • chartColorsOf(theme) supplies the background, grid, axis text, crosshair, axis border and last-price colours. They are pushed with chart.applyOptions({ colors }), and candles use upColor, downColor and the wicks.
  • orderflowColorsOf(theme) derives the footprint, volume profile, bubbles, TPO, POC and VWAP colours from buy, sell, accent, text, poc and vwap.
  • tradingPaletteOf(theme) supplies the order and position lines. It starts from DEFAULT_TRADING_PALETTE or LIGHT_TRADING_PALETTE and applies the theme's trading overrides. It is deliberately not tied to the candle colours.

All of these read the resolved theme object that applyThemeVars receives. A theme switch therefore recolours the chrome (through CSS variables) and the canvas (through these mappings) in the same render. themeFromOxVars() goes the other way: it builds an OxTheme from --ox-* variables a host already publishes.

On this page