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, --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 its data-slot hooks.

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";
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, 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:

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, description } 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 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

FileContents
tokens.json (root), contrast-exceptions.jsonThe DTCG source of truth and the documented contrast shortfalls
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, theme-template.css, tokens.json and the package root (tokenNames) are exported; the rest is listed so you know where a value comes from.