Theming

One stylesheet of CSS custom properties drives every component — colors, control sizes, radii and motion.

Getting started 3 min read

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:

--accent
--ctrl-r-md
Deploy Preview you@company.com

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

Smallsm
32 · r10
Mediummd
40 · r12
Largelg
48 · r14
smmdlg
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:

Button Input Select

Want a squarer product? Change the three radii once:

:root {
  --ctrl-r-sm: 4px;
  --ctrl-r-md: 6px;
  --ctrl-r-lg: 8px;
}

Motion

TokenDefault
--dur-fast200ms
--dur-mid260ms
--dur-slow320ms
--ease-outcubic-bezier(.22, 1, .36, 1)
--ease-springcubic-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

Same component, different timeline.

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;
}