Design tokens
Every visual decision in the system lives in one file: theme.css. Swap that file and the entire UI reskins — buttons, cards, shadows, type, spacing — without touching a line of component CSS.
How tokens work
Tokens are plain CSS custom properties declared on :root. Components read them with var(--token), so a single stylesheet swap cascades everywhere. No build step is required to consume them — drop the file in, and the browser does the rest.
Each shipped theme emits :root, :root[data-theme="name"], so a single file dropped in as your theme.css applies with no markup change. The library's own defaults are emitted under :where(:root) at zero specificity, so any theme — or any token you set from your own :root — outranks them regardless of load order.
/* theme.css — the one file you swap */ :root { --paper: #F7F3EA; --ink: #2A241E; --ai: #1F3A5F; --radius-md: 3px; --shadow-md: 0 4px 18px rgba(42,36,30,.08); } /* any consumer — base, component, or your own CSS */ .button { background: var(--paper-raised); color: var(--ink); border-radius: var(--radius-md); box-shadow: var(--shadow-md); }
Palette
Color tokens split into three intents: neutrals (paper & ink), brand (indigo, vermilion, ochre), and status (success / warning / error / info). Every swatch below reads live from the active theme — pick a new theme and the whole grid reskins.
Each swatch shows the token name plus the resolved CSS value. Open the ThemePicker (the floating disc, lower-right) and pick a different theme — the chips and values rewrite live, no reload required.
Neutrals
Brand
Action (semantic primary)
Status
Surfaces
/* Consume any color token the same way */ .card { background: var(--surface-raised); color: var(--text-primary); border: 1px solid var(--border-default); }
Typography
Four font stacks cover the whole system: a chunky display serif, a reading serif, a handwritten script for accents, and a mono for code. A sans stack anchors the body. Size, weight, and line-height live in their own slots so themes can retune the whole rhythm.
body { font-family: var(--font-primary); font-size: var(--font-size-base); line-height: var(--line-height-normal); } h1 { font-family: var(--font-display); } code { font-family: var(--font-mono); }
Type scale
Themes declare --font-size-base, --line-height-normal, and --font-weight-medium as the canonical hooks. Scale steps are computed from the base so the whole system stays proportional. Each line below renders at the size resolved from the active theme.
h1 { font-size: calc(var(--font-size-base) * 2.5); } h2 { font-size: calc(var(--font-size-base) * 2); } h3 { font-size: calc(var(--font-size-base) * 1.5); } p { font-size: var(--font-size-base); }
Spacing
Spacing is a theme concern like every other token. The numbered scale --space-0 through --space-9 is the source of truth and is contract-required — a theme declares it, and re-proportions the whole page. Values are declared in rem so they honor the user's root font size.
.stack > * + * { margin-top: var(--space-4); } .card { padding: var(--space-5); }
The t-shirt aliases
The six t-shirt names are optional. The library emits them as var() references into the numbered scale — --space-md: var(--space-4) — so they follow whatever the theme sets, automatically. A theme never has to declare them.
They used to be emitted as independent literals: components call space(4) → var(--space-4), but a theme could only set the t-shirt names, so the token the theme set and the token the component read were different variables. That is why every shipped theme used to have byte-identical spacing no matter what it declared.
/* emitted by the library, not by the theme */ --space-xs: var(--space-1); --space-sm: var(--space-2); --space-md: var(--space-4); --space-lg: var(--space-5); --space-xl: var(--space-6);
Radii
Two families ship together: the short --r-sm/md/lg tokens themes use natively, and the longer --radius-sm/md/lg/xl/full aliases the library mixins consume. Both render below at their current theme value — flat in a brutalist mood, generously rounded in a soft one.
.button { border-radius: var(--radius-md); } .avatar { border-radius: var(--radius-full); }
Shadows
Five elevation steps, from a hairline edge to a full modal lift. The Sketchbook theme tunes them as ink bleeding through paper; a glass theme would swap them for frosted bloom with the same token names.
.modal { box-shadow: var(--shadow-2xl); } .popover { box-shadow: var(--shadow-lg); }
Transitions
Motion is a theme concern — a paper theme uses a soft ease, a neon theme might snap. Three durations and one easing cover the system.
.button { transition: background var(--duration-fast) var(--ease); }
The contract
Every theme file must declare the full set of 127 required tokens — even if a given theme sets some of them to neutral values (e.g. --blur-md: none;). That's what guarantees the one-file swap stays lossless: no matter which theme you drop in, the base stylesheet and components always find the slots they read.
A further 36 optional tokens (163 in total) are recognised but not demanded. These are the per-component radius overrides (--btn-radius, --card-radius, --input-radius, --modal-radius, --badge-radius, --tag-radius), the named shadow slots, the logo hooks, and the t-shirt spacing aliases. Leave them out and each one cascades from the generic scale it belongs to.
The authoritative contract lives in two places: CONTRACT.md (human-readable, grouped and typed) and scripts/theme-contract.json (machine-readable, consumed by the validator). Run the validator against any theme file to confirm it covers the contract:
# validate a theme file against the contract node scripts/theme-validator.js public/theme.css
Need a starting point? Download any of the built-in starter themes from /docs/install#download, open it up, and change the values. Every token you'll ever need is already slotted in.