Skip to content
microcharts
GuidesTheming

Theming

Theme microcharts with a couple dozen CSS custom properties, named presets, and a defineTheme() builder — override a token at any scope, derive a full palette from one brand colour, switch presets with a data attribute, keep semantic colors meaning what they say, and get hand-tuned dark mode and forced-colors support for free.

microcharts theming is CSS custom properties, all the way down. There's no theme provider you have to wire up and no Tailwind requirement — set a --mc-* variable at any scope and every chart inside it retunes, with zero re-render and no build step. (Prefer a React component to a raw attribute? MicroProvider wraps the same contract.)

Two ideas carry the whole system: a token is one variable you set, and a scope is where you set it. Learn those two and the rest of this page is just the vocabulary — every colour, weight, and timing you can reach for, and a studio that hands you any combination as ready-to-paste CSS.

Override a token

.brand {
  --mc-accent: #7c3aed;
  --mc-stroke-width: 2;
}

Every chart inside .brand now draws with that accent and stroke weight. Precedence is predictable: a prop wins over a CSS-variable scope, which wins over a preset, which wins over the built-in default.

Per-instance color
DefaultCustomToken

Scope it anywhere

Because the tokens cascade, the scope is yours to choose — the whole app, one brand section, a single card, or one chart. Set a token high and every chart below inherits it; override it lower and only that subtree changes.

// app-wide, once
<html style={{ "--mc-accent": "var(--brand-500)" }}>

// just this card's charts
<div style={{ "--mc-stroke-width": 2, "--mc-gap": "0.15em" }}>
  <SparkBar data={weekly} />
</div>

Bind --mc-accent to your existing brand variable and the charts follow your design system automatically — no per-chart props, no duplicated color values.

The token set

That's the whole mechanism. What's left is the vocabulary: about two dozen custom properties cover the entire surface, and setting any of them at any scope retunes every chart inside. Here are the colour tokens with their built-in light and dark values — every chip is the real shipped default, click to copy the hex:

Semantic — meaning is fixed
--mc-strokeDefault ink — lines, bars, labels
--mc-positiveGood direction — pair with ▲
--mc-negativeBad direction — pair with ▼
--mc-neutralNo-signal marks, baselines
--mc-accentEmphasis — rebind to your brand
--mc-bandNormal-range shading, derived from ink
--mc-moonMoonPhase lit area — warm amber
Categorical — multi-series only
Gold--mc-cat-1
Azure--mc-cat-2
Emerald--mc-cat-3
Sapphire--mc-cat-4
Terracotta--mc-cat-5
Mauve--mc-cat-6

Below is the same set as raw CSS — the colours plus the geometry, motion, and readout-surface tokens — ready to paste and override. (Want a specific style or accent instead of the bare defaults? The studio a little further down builds any combination for you.)

:root {
  /* Semantic colour */

  /* Default ink: lines, bars, labels. Dark mode is hand-tuned, not inverted. */
  --mc-stroke: #1a1917;

  /* Good direction — colour-blind-safe viridian; pair with ▲. */
  --mc-positive: #0e7a5f;

  /* Bad direction — colour-blind-safe terracotta; pair with ▼. */
  --mc-negative: #bd4b2d;

  /* No-signal marks: baselines, inactive points, empty cells. */
  --mc-neutral: #8a8986;

  /* Emphasis colour — rebind to your brand. */
  --mc-accent: #1f6091;

  /* Normal-range shading, derived from the ink. */
  --mc-band: color-mix(in oklab, var(--mc-stroke) 8%, transparent);

  /* MoonPhase lit area — warm amber; mono/print/eink retune it with the ink. */
  --mc-moon: #c1922f;

  /* Categorical palette — matte jewel tones; multi-series only, lightness-ordered.
     Each has a hand-tuned brighter twin on dark surfaces (same hue, lifted). */
  --mc-cat-1: #d2982f;
  --mc-cat-2: #5b9fd4;
  --mc-cat-3: #2e8c66;
  --mc-cat-4: #285788;
  --mc-cat-5: #c2543a;
  --mc-cat-6: #a85c8c;

  /* Geometry */

  /* Light by default for inline use; bump to ~2 when standalone. */
  --mc-stroke-width: 1.5;

  /* Spacing between grouped marks; em-based so it scales with the font. */
  --mc-gap: 0.25em;

  /* Uniform density scale — compact (<1) vs comfortable (>1). Tunes stroke
     weight, label size, and small-multiple gap together; the box set by
     width/height is untouched. */
  --mc-density: 1;

  /* Direct-label text size, relative to the chart's font. */
  --mc-label-size: 0.75em;

  /* Direct-label weight. */
  --mc-label-weight: 400;

  /* `.mc-inline` seats marks on the text baseline (font-independent). This is
     an optional extra optical shift on top — 0 by default; set it only if a
     particular face wants marks a hair off the line. */
  --mc-inline-nudge: 0em;

  /* Extra optical shift for centred marks (arrow, dot, moon, dials, strips) on
     top of the cap-band seat — font-relative, negative lifts. 0em by default:
     the seat already lands on the true cap midpoint, so there is nothing left
     to correct unless a display face has an unusual cap ratio. */
  --mc-glyph-nudge: 0em;

  /* Motion & type */

  /* Motion duration — readout transitions, value pulses, entrance animation. */
  --mc-duration: 300ms;

  /* Ease-out entrance; no bounce or loop. */
  --mc-easing: cubic-bezier(0.22, 1, 0.36, 1);

  /* Inherits the host UI font + tabular numerals by default. */
  --mc-font: inherit;

  /* Face for figures + labels — tracks --mc-font; point at a brand or mono
     face to give numbers their own typeface without touching the page font. */
  --mc-font-numeric: var(--mc-font);

  /* Interactive readout chip (hover, focus, or a pinned selection).
     Defaults to system canvas. */
  --mc-surface: Canvas;
  --mc-surface-ink: CanvasText;
  --mc-surface-edge: color-mix(in oklab, CanvasText 16%, transparent);
}

Semantic vs categorical color

The colour tokens split into two groups, and knowing which is which is what keeps a theme honest.

Semantic tokens keep their meaning. Swapping --mc-accent retints the one emphasis color, but --mc-positive/--mc-negative still mean up and down — so a preset can restyle a chart without ever changing what it says. Color encodes; it never merely decorates.

Categorical colors are a last resort. Most microcharts show one series and never touch the palette. When a chart genuinely needs several, the six --mc-cat-* hues are lightness-ordered for grayscale and colour-vision separation, and gold leads because true yellow is too low-contrast on a light surface.

To recolour the series on one instance without touching the global palette, the categorical charts (SegmentedBar, StackedArea, PartitionStrip, Hypnogram, MicroDonut) take a colors array — cycled per series, overriding --mc-cat-* just there. A rolled-up "Other" segment stays neutral rather than taking a series colour.

Per-instance colors[]
Custom series colours34%26%22%18%

Presets

Setting tokens by hand is one path; a preset is a whole look bundled under one name, applied with a data-mc-theme attribute — pure CSS, zero JavaScript. They change how charts look, never what the data means. modern is what you get with no attribute at all: it's the base token set, not a bundle layered on top, so there's nothing to apply.

Presets
modern
editorial
vivid
mono

What each preset retunes — every other token stays at its default:

Moderndefault

The default — nothing to override.

Editorial

Hairline ink, a signature claret endpoint.

accent#a32236stroke-width1
Mono

One ink — direction carries by shape, never colour.

positive= inknegative= inkaccent= inkneutral= inkmoon= ink
Vivid

Bolder ink and punchier valence; accent stays yours.

positive#0f9e78negative#e24d2estroke-width2
Print

Paper output — near-black ink, hairline weight, valence deepened for CMYK.

stroke#1a1a1aneutral#666666positive#0c6249negative#a33f22accent#14507amoon#7a5a12bandtintstroke-width1.25
E-ink

Grayscale e-paper — sign rides lightness, heavier strokes for low refresh.

stroke#000000positive#000000negative#595959neutral#8c8c8caccent#000000moon#000000bandtintstroke-width2

The last two — print and eink — are output-context bundles rather than a look you'd ship on screen. print pins a near-black ink, deepens the valence colours for CMYK, and thins the stroke to 1.25; eink drops to grayscale and lets sign ride lightness (positive black, negative gray) with a 2 stroke for a low-refresh e-paper panel. Apply them the same way — data-mc-theme="print" on the region you're about to render. Both pin a fixed light-surface ink, so both also ship hand-tuned dark twins: on a dark viewer eink flips to true night mode (white ink on black) and print lifts to a dark-paper ink, while the stroke weight and band identity carry over.

The look presets — editorial, mono, vivid, print, eink — also answer to data-mc-preset, the page-global form: set it once on <html> for an app shell, and a nearer data-mc-theme still wins by proximity. dark is the exception, matching data-mc-theme="dark" only, because it's a colour-scheme scope rather than a look you'd pin app-wide.

Every preset above carries a Copy tokens button, and the studio below lets you pair any preset with your own accent and copy the result for both modes. This site's appearance menu (the palette icon in the nav) applies the same bundles live across every page — and picking an accent there re-hues every chart on the site, including the categorical palette, because it derives one with defineTheme exactly like the studio does. It's the exact token contract you'd use in your own app.

Copy the tokens

Everything above, in one place — the live studio. Pick a style, add your brand accent, choose a mode, and copy the exact --mc-* block, light and hand-tuned dark together, ready to paste into your app. The preview renders real charts in both modes and names each role it paints, so a choice shows up the instant you make it: the emphasis line takes your accent and the categorical bars re-hue to a matched palette derived from it by defineTheme — the same engine the nav's appearance menu uses to re-theme the whole site. What you preview is exactly what you copy, and exactly what the library ships — never an approximation.

Style
Accent
Output
Mode
Tokens
Format
Light
InkAccentUpDown
Dark
InkAccentUpDown
theme.css
:root {  --mc-stroke: #1a1917;  --mc-positive: #0e7a5f;  --mc-negative: #bd4b2d;  --mc-neutral: #8a8986;  --mc-accent: #c2410c;  --mc-band: color-mix(in oklab, var(--mc-stroke) 8%, transparent);  --mc-moon: #c1922f;  --mc-cat-1: #c07157;  --mc-cat-2: #7c6c05;  --mc-cat-3: #479d70;  --mc-cat-4: #007891;  --mc-cat-5: #7b82ca;  --mc-cat-6: #935181;}@media (prefers-color-scheme: dark) {  :root {    --mc-stroke: #eae9e6;    --mc-positive: #45a385;    --mc-negative: #df7856;    --mc-neutral: #9a9a97;    --mc-accent: #f7924e;    --mc-moon: #e0be6f;    --mc-cat-1: #f09c81;    --mc-cat-2: #a69640;    --mc-cat-3: #74c99a;    --mc-cat-4: #30a5c2;    --mc-cat-5: #a4adf8;    --mc-cat-6: #c07aac;  }}

Paste the CSS into any stylesheet and every chart in scope retunes. Prefer the component API? Switch the format to a tokens object and spread it onto MicroProvider — or reach for defineTheme directly to derive that matched palette in code.

A component API, if you prefer

Everything above works through plain attributes and CSS variables — but if you'd rather set the theme in JSX, MicroProvider wraps the same contract in a component. It's hook-free and RSC-safe, so it works in Server Components too.

import { MicroProvider } from "@microcharts/react";

<MicroProvider theme="editorial" tokens={{ "--mc-accent": "var(--brand-500)" }}>
  <Sparkline data={data} />
</MicroProvider>;

It renders a single <div> that sets data-mc-theme (the preset) plus any tokens as inline --mc-* custom properties — identical to writing them by hand. theme="modern" writes no attribute, since modern is the base token set. theme takes any preset (modern, editorial, mono, vivid, dark, plus the output-context print and eink); tokens takes any --mc-* overrides. Reach for whichever reads better in your code — it's the same cascade underneath.

Build a theme from one colour

Setting a token or two by hand is the common case. For a whole brand theme — a matched categorical palette and hand-tuned dark twins for every colour — reach for defineTheme from @microcharts/react/theme. Hand it your brand accent and it derives the rest in OKLCH: a harmonized, colour-blind-safe categorical palette and a lifted dark-mode twin for each token. It never nudges the positive/negative hues off their CVD-safe green/vermillion split, so a derived theme stays as honest as the defaults.

import { defineTheme } from "@microcharts/react/theme";
import { MicroProvider } from "@microcharts/react";

const brand = defineTheme({ accent: "#6d28d9" });

// spread the tokens onto one scope…
<MicroProvider style={brand.style}>
  <SegmentedBar data={mix} />
</MicroProvider>;

// …or emit a global stylesheet — includes a prefers-color-scheme: dark block
const css = brand.css(":root");

One accent, a full derived palette (these are the real values defineTheme returns):

defineTheme({ accent: '#6d28d9' })
Derived palette34%26%18%12%10%

The result carries everything you need for any delivery: vars (a --mc-* object), darkVars (the derived dark twins, empty when dark: false), style (an alias of vars, ready to spread onto MicroProvider or any element), css(selector = ":root") (a string with the prefers-color-scheme: dark block included), toString() (the same as css(":root")), and extend() to spin a variant off an existing theme. Extend a preset, pin any token, or pass an explicit palette when you don't want derivation:

// extend a preset — the brand accent flows through
defineTheme({
  extends: "editorial",
  accent: "var(--brand-500)",
});

// bring your own categorical palette (no derivation)
defineTheme({
  accent: "#6d28d9",
  cat: ["#2563eb", "#db2777", "#65a30d"],
});

// derive a specific number of categorical tones instead of the default six
defineTheme({ accent: "#6d28d9", cat: 3 });

// opt out of accent-seeded derivation, or out of dark twins entirely
defineTheme({ accent: "#6d28d9", derive: false });
defineTheme({ accent: "#6d28d9", dark: false });

// override just some dark twins (the rest stay auto-derived)
defineTheme({ accent: "#6d28d9", dark: { accent: "#a78bfa" } });

// compose a compact variant off an existing theme
defineTheme({ density: 0.85 }).extend({ labelWeight: 500 });

// pin geometry directly — strokeWidth, gap, labelSize, labelWeight, density
defineTheme({ accent: "#6d28d9", strokeWidth: 2 });

It's pure, dependency-free, and tree-shaken out of any bundle that never imports it.

Density

--mc-density is a single scalar that scales stroke weight, label size, and small-multiple gap together — one knob for compact tables (< 1) or an airier standalone layout (> 1). It tunes the ink and type, not the box: the plot size still comes from width/height, so nothing reflows unexpectedly.

/* a denser table of sparklines */
.metrics-table {
  --mc-density: 0.85;
}

One asymmetry worth knowing: stroke and gap scale without limit, but the label stops scaling at 1.25. Direct labels sit in a gutter the chart reserves from a character-count estimate while it draws — server-side, where text cannot be measured. That reservation is fixed at render time and cannot see a CSS variable set later, so past about 1.25 the text outgrows the space held for it and paints outside the chart. Below 1 there is no such limit: the label shrinks inside a gutter already reserved for something bigger, which is always safe.

So --mc-density: 2 gives you double-weight strokes and double gaps, with labels at 1.25×. If you want genuinely larger text, scale the chart — width/height, or --mc-label-size — rather than leaning on density.

The font

--mc-font defaults to inherit, so charts adopt the font of whatever contains them and read as part of your interface. That means the rule is really about your page, not the library: if the surrounding page sets a font-family, charts follow it for free. If it doesn't — as in some minimal starters and code sandboxes — SVG text falls back to the browser default (usually a serif), and labels come out looking wrong.

Two fixes, either works: give your page a font the normal way, or font the charts directly by pointing --mc-font at a stack.

:root {
  /* charts only — the rest of the page is untouched */
  --mc-font: system-ui, sans-serif;
}

Numbers always render with tabular-nums regardless, so columns of figures stay aligned in any font. Want the figures in a dedicated face — a tabular numeric font, or a mono for a data-dense KPI — without changing the label font? Point --mc-font-numeric at it; it defaults to --mc-font, so leaving it alone keeps everything on one typeface. --mc-label-weight (default 400) does the same for label weight.

:root {
  --mc-font: Inter, sans-serif; /* labels */
  --mc-font-numeric: "IBM Plex Mono", monospace; /* figures */
}

The readout surface

The only opaque plane in the whole library is the value chip an interactive chart shows for its active unit — under the pointer, under keyboard focus, or pinned in place after a click, tap, or Enter. Its three --mc-surface* tokens default to the system Canvas, so it adapts to light, dark, and forced-colors with no configuration. Point them at your popover tokens when you want the chip to match a themed surface exactly — one set of tokens covers the hovered, focused, and pinned states alike.

Dark mode and forced colors

Dark values are hand-tuned per token — never auto-inverted, so overriding one light value can't quietly break the dark surface. microcharts follows prefers-color-scheme out of the box, and you can force a scope with data-mc-theme="dark". Charts never paint their own background, so they always sit cleanly on your surface.

If your app toggles theme with a class (next-themes .dark, a [data-theme] attribute, …) rather than the OS media query, rebind the tokens under that class the same way you bind accent — the library's media-query defaults are :where() zero-specificity, so your class rules win:

:root {
  --mc-accent: var(--brand);
}
.dark {
  --mc-stroke: #eae9e6;
  --mc-positive: #45a385;
  --mc-negative: #df7856;
  --mc-accent: var(--brand);
  /* …or data-mc-theme="dark" on a scope, if you prefer the shipped dark bundle */
}

Frosted or translucent surfaces

Default valence inks clear 4.5:1 on opaque white and near-black. On a frosted glass / tinted panel the composited backdrop is quieter, so deepen --mc-stroke / --mc-positive / --mc-negative / --mc-neutral on that surface until contrast holds — don't patch chart CSS. (This docs site does exactly that under :root / .dark so marks stay legible on glass.)

Under Windows High Contrast (forced-colors), the semantic tokens defer to system colors — ink to CanvasText, accent to Highlight, muted marks to GrayText — so a chart stays legible even when your palette is overridden entirely.

prefers-contrast: more is handled too: charts bump --mc-stroke-width to 2 and deepen --mc-band from an 8% to a 16% mix of the ink, so lines and normal-range shading both gain weight without you configuring anything.