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, --destructive, --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 | --shadow-soft … --shadow-elevated, --shadow-glow |
| Motion | --duration-*, --ease-*, and the --animate-* keyframes |
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 required: the package ships an @theme mapping so the
tokens are Tailwind utilities (bg-primary, rounded-lg, shadow-card).
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/components. 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: five complete looks, from HubSpot and Microsoft 365 to a bubble-gum pastel, 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, keyframes and .animate-* classes. Plain CSS; usable on its own in a project that has no Tailwind |
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 reads --color-primary,
which resolves to --primary where it is declared. 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. That
includes --radius: the rounded-* utilities derive their steps from it
at the element, so .panel { --radius: 0 } squares the controls inside
the panel. (The --radius-sm, --radius-md, … tokens are the same steps
for plain CSS, and a plain-CSS consumer may override one directly.) Five
complete looks built this way, with their overrides, are on the
Themes page and in the navbar.
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 twelve 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 / -left / -right, scale-in, scale-out, spin, pulse,
bounce, shake). Entrances and exits run for --duration-normal with
--ease-out; 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.
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, description } 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 and copies src/ to dist/.
Contrast
scripts/check-contrast.mjs computes the WCAG 2 contrast ratio of every
text pair in both modes: each --<x>-foreground on its --<x> surface,
and --foreground, --muted-foreground and every --<hue>-text on
--background. A pair below 4.5:1 (AA, normal text) fails the build unless
it is listed with a reason in contrast-exceptions.json, in which case it
is printed as a warning on every build. The shipped defaults carry six
such exceptions: the Tailwind-500 --primary, --destructive, --success
and --warning surfaces with white text (3.7:1, 3.8:1, 2.3:1, 2.2:1; AA
for large or bold text), --sidebar-primary (the same blue), and
--muted-foreground on --muted in the light (4.4:1). The check reports;
your theme decides. A product that needs AA on small text sets a darker
surface (--primary: #2563eb is 5.2:1) or uses the --<hue>-text tokens,
which all pass. Run it against a theme of your own with
pnpm --filter @zuilib/tokens check-contrast after editing tokens.json,
or lower the bar for a one-off report with --min 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 DTCG source itself, for Style
Dictionary and similar tools.
What's in the package
| File | Contents |
|---|---|
tokens.json (root), contrast-exceptions.json | The DTCG source of truth and the documented contrast shortfalls |
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, theme-template.css, tokens.json and the
package root (tokenNames) are exported; the rest is listed so you know
where a value comes from.