Skip to main content

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:

GroupTokens
ColourSurfaces (--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-*
DepthThe --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 its data-slot hooks.

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";
ImportContents
@zuilib/tokens/styles.cssEverything below plus @import "tailwindcss": the one-line setup
@zuilib/tokens/tailwind.cssThe @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.cssThe density presets and the print rules on their own (both are already inside tokens.css)
@zuilib/tokens/contrastThe 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:

OutputContents
light-tokens.css, dark-tokens.css:root and .dark, every section comment from the group descriptions. Generated files: edit tokens.json
theme-template.cssA 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.tstokenNames (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:

PairsWhatBar
contentPairsevery --<x>-foreground on --<x> (--sidebar-foreground on --sidebar included), and --foreground, --muted-foreground and every --<hue>-text on --background4.5:1
tintPairsevery --<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, --accent4.5:1
uiPairs--border, --input, --ring on --background; --sidebar-border on --sidebar3: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​

FileContents
tokens.json (root), contrast-exceptions.jsonThe DTCG-shaped source of truth and the documented contrast shortfalls (the three hairline borders)
contrast.mjs, contrast.d.tsThe shared WCAG module (@zuilib/tokens/contrast)
density.css, print.cssDensity presets and print rules (both inside tokens.css)
light-tokens.css:root values (generated)
dark-tokens.css.dark overrides (generated)
theme-template.cssStarter brand.css (generated)
tokens.json (dist), tokens.js, tokens.d.tsFlat name map, tokenNames, TokenName (generated)
keyframes.css, animations.cssKeyframes and .animate-* classes
theme.cssTailwind @theme mapping
base.cssBase element styles (Tailwind @apply)
tokens.css, tailwind.css, styles.cssThe 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.