Theming
One stylesheet of CSS custom properties drives every component — colors, control sizes, radii and motion.
TL;DR · same component, new look
- Theming is CSS custom properties. No provider, no JS config, no build step.
- Change a token and every component that reads it follows, even ones you copied months ago.
- Light and dark is one attribute on
<html>.
Override a variable and every component that reads it follows, instantly. Try it — this box reads the same kind of tokens a real component does:
:root { --accent: #0070f3; --ctrl-r-md: 12px; }
Light and dark
data-theme="dark"The theme is an attribute on <html>:
<html data-theme="dark"> <!-- default -->
<html data-theme="light">
tokens.css defines the dark palette on :root and overrides it under
:root[data-theme='light']. Switching is one attribute write:
document.documentElement.dataset.theme = 'light';
localStorage.setItem('theme', 'light');
Set it before first paint (an inline script in <head>) so the page never
flashes the wrong palette.
The token groups
Surfaces and text
These chips read the live variables on this page. Flip the site theme from the navbar and watch them change.
--bgPage background--bg-cardCard / panel surface--bg-elevatedPopovers, menus, dialogs--bg-inputField interiors--borderDefault hairline--textPrimary text--text-secondaryBody copy--text-mutedLabels, hints, captions--border-hover and --border-strong cover hover and emphasis.
Accent and status
--accent and --accent-contrast drive the primary action color.
--success, --danger, --warn and --info back the tone prop that most
components accept.
--accentPrimary action--successtone="success"--dangertone="danger"--warntone="warn"--infoInformational:root {
--accent: #7c5cff;
--accent-contrast: #ffffff;
}
That single change repaints every primary button, focus ring and active state in the app — exactly what the box at the top of this page does.
The control scale
This is the one that keeps a row of mixed components aligned. Height, radius, font size and horizontal padding all come from the same three-step scale:
32 · r10
40 · r12
48 · r14
| sm | md | lg | |
|---|---|---|---|
| Height | --ctrl-h-sm 32px | --ctrl-h-md 40px | --ctrl-h-lg 48px |
| Radius | --ctrl-r-sm 10px | --ctrl-r-md 12px | --ctrl-r-lg 14px |
| Font | --ctrl-fs-sm 13px | --ctrl-fs-md 14px | --ctrl-fs-lg 15px |
| Padding X | --ctrl-px-sm 12px | --ctrl-px-md 14px | --ctrl-px-lg 18px |
A button, an input and a select with size="md" line up pixel for pixel
because all three read --ctrl-h-md:
Want a squarer product? Change the three radii once:
:root {
--ctrl-r-sm: 4px;
--ctrl-r-md: 6px;
--ctrl-r-lg: 8px;
}
Motion
| Token | Default |
|---|---|
--dur-fast | 200ms |
--dur-mid | 260ms |
--dur-slow | 320ms |
--ease-out | cubic-bezier(.22, 1, .36, 1) |
--ease-spring | cubic-bezier(.34, 1.56, .64, 1) |
Under prefers-reduced-motion: reduce, tokens.css zeroes the durations for
you. JS-driven motion has its own guard — see
Effects & motion.
Effects tint
--fx-tint is the light color used by the proximity glow and the press
ripple, written as space-separated RGB because the effects compose it with
alpha:
:root { --fx-tint: 255 255 255; } /* dark theme */
:root[data-theme='light'] { --fx-tint: 0 0 0; } /* light theme */
Scoping a theme to one subtree
Tokens cascade, so a section can carry its own palette:
<section style="--accent: #22c55e; --bg-card: #04140b;">
<vs-button label="Deploy"></vs-button>
</section>
Nothing else in the page changes.
Per-component tokens
Some components expose their own namespaced variables for fine control —
--btn-primary-bg, --inp-border, --sel-*, and so on. They all fall back to
the global tokens, so you only reach for them when you want that one component
to differ:
.checkout vs-button {
--btn-primary-bg: #111;
--btn-primary-fg: #fff;
}