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.
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.
Where the library's rules sit in the cascade
styles.css opens by declaring four cascade layers, and every rule it ships lives in one of them:
@layer microcharts.tokens, microcharts.base, microcharts.charts, microcharts.motion;Tokens are unaffected by this. You set a token on a scope of your own, the library never claims that scope, and your value wins.
Overriding a rule works differently, because layer order beats specificity and the last layer declared wins. If your
app declares its own layers (Tailwind's @layer theme, base, components, utilities, or a framework that sets that up
for you) and styles.css is imported after them, the four microcharts layers are declared last and outrank your utility
classes. A class you add to a chart still lands on the element and still matches a real rule of yours. It loses the
cascade and paints nothing, with no warning anywhere.
Declare the order yourself to fix it. Put the microcharts layers first, above every import in your entry stylesheet, and your own layers then win by being later:
/* app entry CSS, before the imports */
@layer microcharts.tokens, microcharts.base, microcharts.charts, microcharts.motion, theme, base, components,
utilities;Importing styles.css before your framework's CSS reaches the same order with no layer statement, because imports
declare layers as they are parsed.
Either way, the order cuts both ways: once your layers outrank the library's, your reset outranks it too. Tailwind's
Preflight sets svg { display: block } in its base layer, which now beats the library's display: inline-block on
.mc-root — a chart in running text drops onto its own line until you put inline-block back at the call site. With
the reversed order you keep inline placement for free and lose your utilities instead. Pick per app: order your layers
and re-assert inline-block where a chart sits in prose, or keep the library last and reach for a token or !important
on the rule you need.
!important crosses the boundary, because importance inverts layer order: an important declaration in an earlier layer
beats an important one in a later layer, and both beat every normal declaration. Reach for it to unblock a single rule,
and reach for the layer statement above to stop hitting this at all.
Unlayered CSS never meets any of this. Plain rules beat every layered rule regardless of order, so an app with no
@layer of its own already wins.
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.
--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 amberGold--mc-cat-1Azure--mc-cat-2Emerald--mc-cat-3Sapphire--mc-cat-4Terracotta--mc-cat-5Mauve--mc-cat-6Below 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.
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.
What each preset retunes; every other token stays at its default:
The default — nothing to override.
Hairline ink, a signature claret endpoint.
One ink — direction carries by shape, never colour.
Bolder ink and punchier valence; accent stays yours.
Paper output — near-black ink, hairline weight, valence deepened for CMYK.
Grayscale e-paper — sign rides lightness, heavier strokes for low refresh.
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.
: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:
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 --mc-surface, --mc-surface-ink, and
--mc-surface-edge 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.
The fourth token, --mc-surface-shadow, stays a black mix in every theme, and that is deliberate. The chip renders in
the top layer over ground the library cannot know, so its drop shadow reads as occlusion rather than as part of the
palette, and occlusion is dark on a dark surface too. An earlier version mixed CanvasText the way the edge does, which
inverts with the theme and painted a white halo around the chip on dark. On dark the shadow now goes quiet and the 1px
--mc-surface-edge ring carries the separation. Override it if your surface needs a different depth; don't give it a
theme twin.
The active state
The chip names the active unit; the chart also paints it. By default that mark is an accent hairline around the unit plus a light wash inside it, and four tokens retune it. They are opt-in — the library declares none of them, so each one falls back to the default in the comment:
.my-charts {
--mc-active-stroke: var(--mc-accent); /* ring color */
--mc-active-fill: var(--mc-on-fill); /* wash inside the ring */
--mc-active-fill-opacity: 0.2; /* its strength; 0 leaves a bare outline */
--mc-rest-opacity: 1; /* every OTHER mark, while something is active */
}The wash is why the ring reads on a mark the chart already emphasizes. SparkBar inks its endpoint bar with
--mc-accent, and an accent outline on an accent fill cancels out — so the outline alone left the most-looked-at bar in
the chart as the one that answered the pointer least.
Set --mc-rest-opacity below 1 and every mark that isn't the active one steps back while the pointer is down. The chart
is what knows which mark that is, so this is the one treatment you cannot write from the outside. Pair it with a solid
fill to lift the picked unit out of its own dimmed self:
.my-charts {
--mc-rest-opacity: 0.25;
--mc-active-fill: var(--mc-accent);
--mc-active-fill-opacity: 1;
}Under forced colors all four collapse back to a system-accent outline: a themed ink was picked against your palette, not the user's, and an opacity ramp has nothing to say in a two-ink mode.
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. Charts never paint their own background, so they sit on whatever surface you give them.
The library reads the theme two ways. prefers-color-scheme is the default, and data-mc-theme="light" or
data-mc-theme="dark" on any scope pins that subtree regardless of the OS.
Tell the charts which theme your app picked
If your app switches theme with a class, the charts do not see it. .dark on <html> is what next-themes,
fumadocs, and most Tailwind setups write, and it means nothing to a stylesheet that is reading prefers-color-scheme. A
reader on a light OS who picks dark mode gets your dark chrome with light-tuned charts on it, and a reader on a dark OS
who picks light gets the reverse. Both are legible enough to ship by accident and wrong enough to notice.
Set data-mc-theme wherever you set the class, and the two agree:
<html className={theme} data-mc-theme={theme}>That hands the charts the shipped dark bundle, hand-tuned token by token. If you'd rather paint them from your own
palette, rebind the tokens under your class instead. The library's 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);
}Rebinding by hand means rebinding --mc-on-fill too. It is the ink that reads on a saturated fill, it flips between
themes, and it is the one nobody restates: leave it on the dark branch under a light palette and the in-fill labels on
TraceFold, TimeInRange, and PartitionStrip drop to around 3.3:1 while every other token on the page is correct.
data-mc-theme carries it for you.
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.