Tokens
Package: @zuilib/tokens · pure CSS at runtime, no JavaScript.
@zuilib/tokens defines the variables that control how an app looks:
colours, shapes, type, spacing, control density, shadows and motion. Each
one is a CSS custom property with a light and a dark value. Every
@zuilib/* package reads these names, so overriding a value restyles
everything built on them.
The package covers the whole vocabulary a component library needs:
| Group | Tokens |
|---|---|
| Colour | Surfaces (--background, --card, --popover, --border, --input, --ring), semantic pairs (--primary / --primary-foreground, --secondary, --accent, --muted, --danger, --success, --warning, --info), the sidebar palette, gradients |
| Shape | --radius and the --radius-sm … --radius-full steps |
| Type | --font-sans, --font-mono, the --text-* scale, --font-weight-* |
| Spacing | --spacing, the unit every gap and padding derives from |
| Component sizes | --control-height-*, --control-padding-x-*, --button-padding-x-*, --card-padding-*, --table-cell-padding-*, --drawer-size-*, --container-width-* |
| Depth | The --shadow-1 … --shadow-4 scale, --shadow-card, --shadow-glow |
| Motion | --duration-*, --ease-*, and the --animate-* keyframes |
| Editor | --zui-code-*, --zui-table-rail-*: the text editor's code palette (light and dark) and table rails, so a brand themes them from the same file (the canvas chrome's --zui-drawing-* tokens ship with @zuilib/text-editor itself) |
| Contract | --zui-theme-version, the version of the contract (a theme may set its own) |
Every size variant a component can take (small, medium and large controls, cards, table cells, drawers, containers) is already a variable, so you get a complete, consistent system out of the box and change any value when you want to.
Tailwind v4 is optional: tokens.css is plain CSS, and the package also
ships an @theme mapping that makes the tokens Tailwind utilities
(bg-primary, rounded-lg, shadow-card) when you use it. Dark mode is
built in from the start: every colour has a dark value, and a .dark
class on <html> switches the whole app.
There are two ways to use the package:
- Build your own components on it. Write your library against the token names and it is themeable and dark-mode ready from day one.
- Use
@zuilib/primitives. A generalised component system built on these tokens. Retheme it by overriding tokens, and restyle any individual component through itsdata-slothooks.
The Themes page shows the payoff: six complete looks, from HubSpot and Microsoft 365 to Palantir Foundry's Blueprint blue, each a single block of CSS, applied to this whole site from the picker in the navbar.
pnpm add @zuilib/tokens
Setup
One import when the tokens own your Tailwind setup:
@import "@zuilib/tokens/styles.css";
Or, when you already import Tailwind yourself:
@import "tailwindcss";
@import "@zuilib/tokens/tailwind.css";
@import "@zuilib/tokens/tokens.css";
| Import | Contents |
|---|---|
@zuilib/tokens/styles.css | Everything below plus @import "tailwindcss": the one-line setup |
@zuilib/tokens/tailwind.css | The @theme mapping (bg-primary, border-border, rounded-md, …) and base element styles. Import after @import "tailwindcss" |
@zuilib/tokens/tokens.css | :root light values, .dark overrides, the density presets, print rules, keyframes and .animate-* classes. Plain CSS; usable on its own in a project that has no Tailwind |
@zuilib/tokens/density.css, @zuilib/tokens/print.css | The density presets and the print rules on their own (both are already inside tokens.css) |
@zuilib/tokens/contrast | The WCAG module (colour maths, the contrast pairs, the exceptions format) that the build, zui theme check and this site's customizer share |
The @theme block maps every token to a Tailwind name, so the usual
utilities work: bg-primary text-primary-foreground, border-border,
text-muted-foreground, rounded-lg, shadow-card, bg-gradient-hero.
Opacity modifiers work too (bg-primary/20). tailwind.css also applies
the base element styles: body gets bg-background text-foreground font-sans antialiased, selection uses --primary at 20%, scrollbars
follow --muted, and number inputs lose their spinner.
In plain CSS the same tokens are var(--…):
.card {
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
Dark mode
Dark values are overrides under a .dark class. Toggle it on <html>:
document.documentElement.classList.toggle('dark', isDark)
Put the class on <html> or another common ancestor: the tokens are
declared on :root, and Tailwind's bg-primary compiles straight to
background-color: var(--primary) (the mapping is @theme inline reference, so no --color-primary exists at runtime), which resolves
where the element sits. A .dark wrapper lower in the tree re-themes its
own subtree only.
To follow the operating system:
const media = window.matchMedia('(prefers-color-scheme: dark)')
const apply = () => document.documentElement.classList.toggle('dark', media.matches)
apply()
media.addEventListener('change', apply)
Set it before first paint (an inline script in <head>, or on the server
from a cookie) so a dark page renders dark on the first frame.
Customising
Override any token after importing the package. Overrides on :root
retheme the whole app; on a class or element selector, one subtree:
@import "@zuilib/tokens/tokens.css";
:root {
--primary: #0f766e;
--primary-foreground: #ffffff;
--radius: 0.25rem;
}
.dark {
--primary: #2dd4bf;
--primary-foreground: #042f2e;
}
Every component built on the tokens picks the new values up, and the Tailwind utilities follow because they alias the custom properties.
Shape has one knob and five steps. --radius is the knob: every
rounded-* utility carries the derivation itself
(.rounded-lg { border-radius: calc(var(--radius) + 4px) }) and resolves
it at the element, so --radius: 0 squares everything and --radius: 1rem
rounds everything, on :root for the product or on a class
(.panel { --radius: 0 }) for one subtree. The --radius-sm …
--radius-3xl tokens on :root carry the same derivation for plain CSS;
the utilities do not read them, so a step overridden on :root changes
plain-CSS uses only. To move one step for the utilities too, redeclare it
in your own @theme inline { --radius-lg: … } after theme.css.
Six complete looks built this way, with their overrides, are on the Themes page and in the navbar.
Density
Three presets ship in density.css (inside tokens.css): put
data-zui-density="compact", "comfortable" or "spacious" on <html>
for the whole app or on any ancestor for a region. Each preset overrides
--spacing, --control-height-*, --control-padding-x-*,
--button-padding-x-*, --card-padding-* and --table-cell-padding-*,
so every gap, control and cell follows; comfortable restates the
shipped defaults, which is what makes a comfortable island inside a
compact region work. A preset is nothing but those token overrides, so a
theme that sets the same tokens on :root is replaced inside the region.
Print
print.css (inside tokens.css) applies under @media print: white
paper, no box or text shadows anywhere, cards (data-slot="card"),
tables, rows, figures and code blocks kept whole across page breaks,
headings kept with what follows, and any element with
data-print="hide" removed. Mark toolbars, drawers and floating chrome
with that attribute.
Versioning
tokens.json carries a $version (semver), emitted as
--zui-theme-version: "2.0.0" on :root. A theme sets the same name to
its own version (--zui-theme-version: "2.1.0", quoted); zui theme check prints it and zui theme diff prints the change between two files
(theme validation).
Theme template
@zuilib/tokens/theme-template.css is a brand.css to copy: every token
with its default, commented out, grouped and described, with the
dark-mode entries under .dark. Uncomment what your brand changes and
import it after the tokens.
@import "@zuilib/tokens/tokens.css";
@import "./brand.css";
Animations
tokens.css ships fourteen keyframes, an --animate-* token for each
(--animate-spin: zui-spin 1s linear infinite) and the matching
.animate-* classes (fade-in, fade-out, slide-in-from-top /
-bottom / -start / -end, scale-in, scale-out, spin, pulse,
bounce, shake, skeleton-wave, progress-indeterminate). Entrances and exits run for --duration-normal with
--ease-decelerate; spin, pulse and bounce loop (their keyframes are
zui-spin, zui-pulse, zui-bounce, so they coexist with Tailwind's
own). They are plain classes, so they work with or without Tailwind; with
Tailwind the same tokens back the animate-* utilities and their
variants. Override the token to retime one. The full list is in the
reference.
Source of truth
Every value ships from one file, tokens.json in the package root, in the
W3C Design Tokens (DTCG) shape:
nested groups, $type, $value, $description. A colour, gradient or
shadow carries one value per mode ({ "light": …, "dark": … }); a scalar
(radius, font, control height, duration) carries one value for both. A
{path.to.token} inside a value is an alias and compiles to var(--…),
which is how radius.sm stays calc({radius.DEFAULT} - 2px) and follows
--radius wherever it is set.
"primary": {
"$description": "Primary",
"DEFAULT": { "$value": "#2563eb" },
"foreground": { "$value": { "light": "#ffffff", "dark": "#ffffff" } },
"text": { "$value": { "light": "#1d4ed8", "dark": "#60a5fa" } }
}
A token's custom property is its path joined with - (control.height.md
is --control-height-md); a DEFAULT segment is dropped and the color
group is transparent, so color.primary.DEFAULT is --primary. A group's
$description names the section its tokens (and those of any undescribed
group nested inside it) are emitted under. A token with $deprecated
stays in the contract for compatibility but no component reads it; the
sidebar and gradient tokens and --shadow-glow are marked so.
pnpm --filter @zuilib/tokens build runs scripts/build-tokens.mjs
(Node, no dependencies), which generates:
| Output | Contents |
|---|---|
light-tokens.css, dark-tokens.css | :root and .dark, every section comment from the group descriptions. Generated files: edit tokens.json |
theme-template.css | A starter brand.css: every token, commented out, with its default and description |
tokens.json (dist) | Flat "--name": { light, dark, type, section, description, deprecated } for tooling |
tokens.js, tokens.d.ts | tokenNames (a readonly tuple of every custom property) and the TokenName type |
The build then runs the contrast check below, regenerates the
reference page of this site from the same file
(scripts/build-reference.mjs; CI fails when the page is stale) and
copies src/ to dist/.
Contrast
@zuilib/tokens/contrast is the one WCAG 2 implementation every ZUI tool
uses: parseColor (hex, rgb(), hsl(), oklch(), oklab(), the named
colours, light-dark(), color-mix() in srgb / oklab / oklch), over,
contrast, and the four pair derivations the checks share:
| Pairs | What | Bar |
|---|---|---|
contentPairs | every --<x>-foreground on --<x> (--sidebar-foreground on --sidebar included), and --foreground, --muted-foreground and every --<hue>-text on --background | 4.5:1 |
tintPairs | every --<hue>-text on its hue at 10% over --background (the tinted badge or callout the text token exists for) | 4.5:1 |
crossSurfacePairs | --foreground on --muted, --accent, --secondary, --card, --popover; --muted-foreground on --card, --popover, --muted, --accent | 4.5:1 |
uiPairs | --border, --input, --ring on --background; --sidebar-border on --sidebar | 3:1 |
scripts/check-contrast.mjs runs every pair in both modes on each build.
A pair below its bar fails unless it is listed with a reason in
contrast-exceptions.json, in which case it prints as a warning; an
exception whose pair passes again fails the build as stale, and an
unparsable colour fails. Every text pair passes with no exception (the
lowest is --danger-foreground on --danger at 4.83:1). The
three exceptions shipped are the hairline borders (--border light,
--input, --sidebar-border), which are decorative by design: every
control has a filled surface and a focus ring above 3:1. zui theme check
holds the same pairs to the same bars (theme
validation), so a theme that passes there passes the
bar the defaults pass. Run the check by hand with
pnpm --filter @zuilib/tokens check-contrast, or lower the text bar for
a one-off report with --min-contrast 3.
Typed names
import { tokenNames, type TokenName } from '@zuilib/tokens'
const name: TokenName = '--primary' // typo → type error
tokenNames.includes('--radius') // true
@zuilib/tokens/tokens.json is the flat map ("--primary": { light, dark, type }) for a docs site, a Figma sync or a lint rule;
@zuilib/tokens/tokens.dtcg.json is the source itself: DTCG-shaped
($type, $value, $description, groups) with the ZUI mode extension
(a $value keyed { light, dark }) and CSS-string composites, so Style
Dictionary and similar tools need a small custom parser for it rather than
loading it as is.
What's in the package
| File | Contents |
|---|---|
tokens.json (root), contrast-exceptions.json | The DTCG-shaped source of truth and the documented contrast shortfalls (the three hairline borders) |
contrast.mjs, contrast.d.ts | The shared WCAG module (@zuilib/tokens/contrast) |
density.css, print.css | Density presets and print rules (both inside tokens.css) |
light-tokens.css | :root values (generated) |
dark-tokens.css | .dark overrides (generated) |
theme-template.css | Starter brand.css (generated) |
tokens.json (dist), tokens.js, tokens.d.ts | Flat name map, tokenNames, TokenName (generated) |
keyframes.css, animations.css | Keyframes and .animate-* classes |
theme.css | Tailwind @theme mapping |
base.css | Base element styles (Tailwind @apply) |
tokens.css, tailwind.css, styles.css | The three entry points above |
The three CSS entry points, density.css, print.css,
theme-template.css, tokens.json, contrast and the package root
(tokenNames) are exported; the rest is listed so you know where a value
comes from.