Skip to content
microcharts
GuidesTheming

Theming

How to theme microcharts by setting a --mc-* custom property at any scope, applying a named preset, or deriving a whole palette from one accent with defineTheme().

microcharts theming is CSS custom properties. There's no theme provider 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 system. A token is one variable you set; a scope is where you set it. The rest of this page is the vocabulary: every color, weight, and timing you can set, plus 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 runs one way: 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, you choose the scope: 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 every chart follows your design system without a per-chart prop or a duplicated color value.

The token set

That's the whole mechanism; the rest is 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 color tokens with their built-in light and dark values. Every chip is the shipped default; click one 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 colors 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 further down builds any combination for you.

:root {
  /* Semantic color */

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

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

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

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

  /* Emphasis color — 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: #bc5138;
  --mc-cat-6: #a55a89;

  /* 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 color tokens split into two groups, and the split decides what a theme is allowed to change.

Semantic tokens keep their meaning. Swapping --mc-accent retints the one emphasis color; --mc-positive and --mc-negative still mean up and down. A preset can restyle a chart without changing what it says.

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

To recolor the series on one instance without touching the global palette, the categorical charts (SegmentedBar, StackedArea, PartitionStrip, Hypnogram, MicroDonut) take a colors array. It cycles per series and overrides --mc-cat-* for that instance only. A rolled-up "Other" segment stays neutral instead of taking a series color.

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

Presets

A preset is a whole look bundled under one name, applied with a data-mc-theme attribute. It's pure CSS with zero JavaScript, and it changes how charts look, never what the data means. modern is what you get with no attribute at all: it's the base token set rather than a bundle layered on top, so it needs no attribute.

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 colors 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 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 color-scheme scope rather than a look you'd pin app-wide.

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

Copy the tokens

The studio puts everything above in one place. Pick a style, add your brand accent, choose a mode, and copy the --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 as 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 what you copy, and what the library ships.

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: #2f52d4;  --mc-band: color-mix(in oklab, var(--mc-stroke) 8%, transparent);  --mc-moon: #c1922f;  --mc-cat-1: #6b87cb;  --mc-cat-2: #8c548c;  --mc-cat-3: #c26e65;  --mc-cat-4: #876701;  --mc-cat-5: #5a9b62;  --mc-cat-6: #007a86;}@media (prefers-color-scheme: dark) {  :root {    --mc-stroke: #eae9e6;    --mc-positive: #45a385;    --mc-negative: #df7856;    --mc-neutral: #9a9a97;    --mc-accent: #528dff;    --mc-moon: #e0be6f;    --mc-cat-1: #95b2fa;    --mc-cat-2: #b87db8;    --mc-cat-3: #f1998f;    --mc-cat-4: #b2903e;    --mc-cat-5: #85c78c;    --mc-cat-6: #23a8b7;  }}

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 call defineTheme directly to derive that matched palette in code.

The MicroProvider component

Everything above works through plain attributes and CSS variables. 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.

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. Both forms resolve to the same cascade, so use whichever reads better in your code.

Build a theme from one color

Setting a token or two by hand is the common case. For a whole brand theme, with a matched categorical palette and hand-tuned dark twins for every color, use defineTheme from @microcharts/react/theme. Give it your brand accent and it derives the rest in OKLCH: a harmonized, color-blind-safe categorical palette and a lifted dark-mode twin for each token. It never moves the positive/negative hues off their CVD-safe green/vermillion split, so a derived theme encodes direction the way the defaults do.

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 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.

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

One asymmetry: 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 safe.

So --mc-density: 2 gives you double-weight strokes and double gaps, with labels at 1.25×. For larger text, scale the chart with width/height or --mc-label-size instead of leaning on density.

The font

--mc-font defaults to inherit, so charts adopt the font of whatever contains them. If the surrounding page sets a font-family, charts follow it with no configuration. 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 point --mc-font at a stack for the charts alone.

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

Numbers always render with tabular-nums, 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) sets label weight the same way.

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

The readout surface

The one opaque plane in the 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 the chip adapts to light, dark, and forced-colors with no configuration. Point them at your popover tokens to match a themed surface. 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 break the dark surface. microcharts follows prefers-color-scheme by default, and you can force a scope with data-mc-theme="dark". Charts never paint their own background, so they sit on whatever surface you give them.

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 or tinted panel the composited backdrop is quieter, so deepen --mc-stroke, --mc-positive, --mc-negative, and --mc-neutral on that surface until contrast holds. Rebind the tokens rather than patching chart CSS. (This docs site does that under :root and .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. 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.