@zuilib/tokens
v0.2.1
Published
ZUI — design tokens: one tokens.json (W3C DTCG) generating the light / dark CSS, a brand.css template, a typed name list and the shared WCAG contrast module
Maintainers
Readme
@zuilib/tokens
The ZUI design tokens: colours, shape, type, control density, motion,
shadows, keyframes, animation utilities, density presets and print rules,
in light and dark. Pure CSS at runtime, plus one JavaScript module
(@zuilib/tokens/contrast) that carries the WCAG maths every ZUI tool
shares. Every @zuilib/* package themes itself from these custom
properties; a design system built on ZUI is a set of overrides of them.
Entry points
| Import | Needs Tailwind | Contents |
|---|---|---|
| @zuilib/tokens/tokens.css | no | :root light values, .dark overrides, density presets, print rules, keyframes, .animate-* classes. The complete contract |
| @zuilib/tokens/density.css, @zuilib/tokens/print.css | no | The density presets and the print rules alone (both are inside tokens.css) |
| @zuilib/tokens/contrast | n/a (JS) | Colour maths, the contrast pairs and the exceptions format; see below |
| @zuilib/tokens/tailwind.css | yes (v4) | @theme mapping (bg-primary, border-border, rounded-md, …) and base element styles. Import after @import "tailwindcss" |
| @zuilib/tokens/styles.css | yes (v4) | All of the above plus @import "tailwindcss" — the one-line setup |
/* Tailwind app: one line */
@import "@zuilib/tokens/styles.css";
/* Or, if you already import tailwindcss yourself */
@import "tailwindcss";
@import "@zuilib/tokens/tailwind.css";
@import "@zuilib/tokens/tokens.css";
/* No Tailwind at all */
@import "@zuilib/tokens/tokens.css";Using @zuilib/primitives? Import one of its stylesheets instead
(@zuilib/primitives/styles.css, tailwind.css, zui.css or
zui-no-preflight.css); each one already includes these tokens, so they are
not imported twice.
Dark mode is a dark class on <html> (or on any ancestor). :root sets
color-scheme: light, .dark sets color-scheme: dark, so native form
controls and scrollbars follow.
Source of truth
tokens.json (W3C DTCG: groups, $type, $value, $description; colours,
gradients and shadows carry { "light": …, "dark": … }; a top-level
$version; $deprecated on tokens no component reads) is the only file
to edit. pnpm --filter @zuilib/tokens build runs scripts/build-tokens.mjs,
which generates src/light-tokens.css, src/dark-tokens.css,
src/theme.css (the Tailwind @theme inline reference alias map;
scripts/theme-css.mjs carries the one thing tokens.json cannot say —
which groups Tailwind exposes and under what namespace),
src/theme-template.css (a commented starter brand.css with every token
and its default) and dist/tokens.{json,js,d.ts} (the flat
"--name": { light, dark, type, section, description?, deprecated? } map,
tokenNames and the TokenName type), then runs
scripts/check-contrast.mjs, then scripts/build-reference.mjs (the docs
site's token reference page, skipped when the site is not checked out)
and copies src/ to dist/. A group's $description names the section
its tokens are emitted under; an undescribed group joins the nearest
described ancestor.
import { tokenNames, type TokenName } from '@zuilib/tokens'Contrast
src/contrast.mjs, published as @zuilib/tokens/contrast, is the one
WCAG 2 implementation the tokens build, zui theme check / zui theme
diff and the docs site use: parseColor (hex, rgb(), hsl(),
oklch(), oklab(), named colours, light-dark(), color-mix() in
srgb / oklab / oklch), over, luminance, contrast, toHex, the pair
derivations (contentPairs, tintPairs, crossSurfacePairs, uiPairs,
allPairs), pairRatio and the contrast-exceptions.json helpers
(validateExceptions, readExceptions, exceptionFor).
scripts/check-contrast.mjs evaluates every pair in both modes on each
build:
| Pairs | | Bar |
|---|---|---|
| content | every --<x>-foreground on --<x>, --sidebar-foreground on --sidebar, --foreground / --muted-foreground / every --<hue>-text on --background | 4.5:1 |
| tint | every --<hue>-text on its hue at 10% over --background | 4.5:1 |
| cross | --foreground on --muted, --accent, --secondary, --card, --popover; --muted-foreground on --card, --popover, --muted, --accent | 4.5:1 |
| ui | --border, --input, --ring on --background; --sidebar-border on --sidebar | 3:1 |
A pair below its bar fails unless it is listed with a reason in
contrast-exceptions.json (then it is a warning that prints on every
build); a listed pair that passes again fails as stale; an unparsable
colour fails. Every text pair passes with no exception (lowest 4.83:1);
the three exceptions are the hairline borders (--border light,
--input, --sidebar-border), decorative by design since every control
has a filled surface and a focus ring above 3:1. pnpm test runs the
unit tests and the check.
Version
$version in tokens.json is the contract version, emitted as
--zui-theme-version: "1.0.0" on :root. A theme sets the same name
(quoted semver) to its own version; zui theme check prints it and
zui theme diff a.css b.css prints the change.
Retheming
Override tokens after importing. On :root the whole product changes; on
a class, one subtree:
:root {
--primary: #0f766e;
--primary-foreground: #ffffff;
--radius: 0.25rem;
--font-sans: "Inter", system-ui, sans-serif;
--control-height-md: 2.25rem;
}
.dark {
--primary: #2dd4bf;
--primary-foreground: #042f2e;
}
.brand-panel {
--primary: #b91c1c;
--radius: 0;
}A theme you ship is the same mechanism with a name. The conventions the
ZUI tooling and docs use: a .theme-x { … } block carries the light
values and .dark .theme-x { … } the dark ones, applied to <html> for
the whole product or to any element for one subtree; the attribute form
html[data-zui-theme="x"] works identically (the docs site's navbar
picker sets data-zui-theme on <html>). zui theme check parses all
of these scopes — :root, .theme-x, html[data-zui-theme="x"], and
.dark … / @media (prefers-color-scheme: dark) for dark — against this
contract. Density is deliberately not part of a theme:
data-zui-density="compact" | "comfortable" | "spacious" on <html> or
any ancestor applies a preset of the spacing and control tokens (see
Density), so any theme combines with any density.
Every Tailwind utility below compiles to the token itself
(.bg-primary { background-color: var(--primary) }), which is why a
subtree override retheme works. The theme mapping in src/theme.css is
@theme inline reference: inline puts the var() into the utility,
reference stops Tailwind emitting its own :root copy of the names, so
a token and its Tailwind name may coincide (--radius-md, --shadow-card,
--font-sans) without a self-referencing loop.
Radius is one knob and five steps. --radius moves every rounded-*
utility, since each carries the derivation itself
(.rounded-lg { border-radius: calc(var(--radius) + 4px) }) and resolves
it at the element: on :root it reshapes the product, on a class
(.panel { --radius: 0 }, .brand { --radius: 999px }) one subtree. The
--radius-* step 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.
Density
density.css (inside tokens.css) ships three presets on a data
attribute: <html data-zui-density="compact"> for the app, or the same
attribute on any ancestor for a region. Each overrides --spacing,
--control-height-*, --control-padding-x-*, --button-padding-x-*,
--card-padding-* and --table-cell-padding-*; comfortable restates
the shipped defaults so a comfortable island inside a compact region
works. A preset is only those token overrides, so a theme that sets the
same tokens on :root is replaced inside the region.
print.css (inside tokens.css), under @media print: white paper, no
box or text shadows, [data-slot="card"], tables, rows, figures and
pre kept whole across page breaks, headings kept with what follows, and
[data-print="hide"] removed.
Tokens
Every name, with its light and dark default, description and the Tailwind
utility that reads it, is generated from tokens.json into the docs
site's token reference. The
sections: core surfaces and text, border and input, the semantic pairs
(--primary, --secondary, --danger, --muted, --accent,
--popover, --card, --success, --warning, --info, each with its
-foreground, and --<hue>-text for the hue as small text), sidebar and
gradients (deprecated: kept for compatibility, no component reads them),
shadows, shape (--radius and the steps), type (--font-*, --text-*,
--font-weight-*), spacing, control density (--control-height-*,
--control-padding-x-*, --button-padding-x-*), component boxes
(--card-padding-*, --table-cell-padding-*, --drawer-size-*,
--container-*, --dialog-*, --anchor-max-height, --slider-*,
--tooltip, --avatar-ring), motion (--duration-*, --ease-*,
--animate-*) and the text editor namespaces (--zui-code-* with a light and a dark palette, --zui-table-rail-*).
Every colour utility takes the usual modifiers (bg-primary/10,
hover:bg-primary/90). tokens.css ships fourteen keyframes, one
--animate-* token per animation and a matching .animate-* class; the
looping ones (spin, pulse, bounce) carry a zui- prefix so
Tailwind's own are never emitted alongside them.
