Theming
Every visual value in a component is a token: bg-primary compiles to
background-color: var(--primary), rounded-md to
border-radius: var(--radius-md), h-(--control-height-md) reads the
control height. So theming is one mechanism, redeclare variables, applied
at one of two scopes.
| Scope | Where | Effect |
|---|---|---|
| Global | :root (light) and .dark (dark) | The whole app, both colour modes |
| Subtree | Any selector, .brand-x, [data-tenant="acme"], a style attribute | Only that subtree. Custom properties resolve from the nearest ancestor that declares them |
Both work because the @theme mapping in @zuilib/tokens is inline:
utilities point straight at your variable. The
Tokens reference lists every name; this page is
about which ones a brand touches.
Global: brand.css
A brand file is a few dozen lines. Import it after the components' stylesheet so it wins by source order.
/* brand.css — after @import "@zuilib/primitives/styles.css" */
:root {
--primary: #7c3aed;
--primary-foreground: #ffffff;
--primary-text: #6d28d9; /* readable on --background */
--ring: #7c3aed;
--radius: 999px; /* pills everywhere */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--control-height-md: 2.25rem; /* denser controls */
--button-padding-x-md: 1.25rem;
}
.dark {
--primary: #a78bfa;
--primary-foreground: #1e1b4b;
--primary-text: #c4b5fd;
--ring: #a78bfa;
}
/* app.css */
@import "@zuilib/primitives/styles.css";
@import "./brand.css";
Subtree: one page, two brands
The same overrides on a class instead of :root. Everything inside
follows; everything outside stays on the site's theme (pick another in the navbar). This is how a
multi-tenant app, an embedded widget or a marketing block on a product page
gets its own look.
Portalled panels (Dialog, Drawer, Menu, an anchored Select or Popover)
render under <body>, outside the subtree. Put the brand class on <html>
or <body> when you need them themed too, or pass portal={false} where a
component offers it.
The knobs
Colour pairs
Each surface colour has a -foreground for what sits on it; keep the pair
in contrast. -text variants are the same hue darkened or lightened to be
readable as text on --background (link buttons, subtle badges, messages).
| Tokens | Used by |
|---|---|
--background / --foreground | Page ground, outline and ghost buttons, fields |
--primary / --primary-foreground / --primary-text / --primary-border | Primary buttons, checked states, active tabs, links, focus |
--secondary, --accent, --muted (+ -foreground) | Secondary buttons, hover surfaces, disabled and helper text |
--danger, --success, --warning, --info (+ -foreground, -text, -border) | Status: alerts, badges, field errors, invalid fields |
--card, --popover (+ -foreground) | Card and overlay surfaces |
--border, --input, --ring | Hairlines, field borders, keyboard focus ring |
Shape: one --radius knob
Each rounded-* utility derives from the one --radius knob at the
element it styles (md is the value, sm two pixels less, lg four
more), so --radius: 0 squares every corner and works on a subtree
(.brand { --radius: 999px } makes pills of everything inside it). The
--radius-sm/md/lg/xl step tokens on :root carry the same derivation
for plain-CSS readers (the text editor, your own stylesheets); the
utilities do not read them, so overriding a step reshapes plain-CSS uses
only. To move one step for the utilities too, redeclare it in your own
@theme inline block after the ZUI import. zui theme check warns when
a step override cannot reach the utilities (inert-override).
Type
--font-sans, --font-mono, the --text-* scale with its
--text-*-line-height pairs, and --font-weight-*. The components size
text with the scale; change --text-sm and every small label follows.
Control density
Sizes are tokens, so they change from CSS:
| Token | Default | Reads it |
|---|---|---|
--control-height-sm/md/lg | 2rem / 2.5rem / 3rem | Button, Input, NativeSelect, Select, Combobox, NumberInput, SearchInput, PinInput |
--control-padding-x-sm/md/lg | 0.75rem / 1rem / 1rem | Fields |
--button-padding-x-sm/md/lg | 0.75rem / 1rem / 1.5rem | Button |
--card-padding-sm/md/lg | 4 / 6 / 8 × --spacing | Card |
--table-cell-padding-x/y-sm/md | Table | |
--drawer-size-sm/md/lg | 20rem / 28rem / 40rem | Drawer |
--container-width-*, --container-padding | Container | |
--spacing | 0.25rem | The base unit every gap-* / p-* utility multiplies |
Motion
--duration-fast/normal/slow and --ease-standard/--ease-decelerate drive every
transition and the --animate-* recipes. Reduced motion is honoured by the
components; you can also set the durations to 0ms for a static brand.
Dark mode
.dark redeclares the colour tokens; scalars (radius, type, density) are
declared once on :root and inherited. A brand overrides colours twice
(:root and .dark) and everything else once.
Density: data-zui-density
@zuilib/tokens/density.css (inside tokens.css, so every stylesheet
entry has it) ships compact, comfortable and spacious on one
attribute. <html data-zui-density="compact"> sets the app; the same
attribute on any ancestor sets a region. Each preset is a set of token
overrides (--spacing, the control heights and paddings, card and table
cell padding), nothing else, so a theme that sets the same tokens is
replaced inside the region. A theme file does not carry density; this site's
Customize control emits the attribute next to the theme class.
Print
@zuilib/tokens/print.css (also inside tokens.css) applies under
@media print: white paper, no shadows, cards, tables and code blocks kept
whole across pages, and [data-print="hide"] removed. Dialog, Drawer,
Popover and Tooltip hide themselves in print.
Several themes in one app: the naming convention
A theme is a pair of blocks named after it: .theme-acme for light and
.dark .theme-acme for dark, the shape every file on Themes
and the site's Customize control produces. Mount it with the class on
<html> (the app) or on any element (a tenant, an embed).
When the user picks the theme at runtime, keep the choice in one place and map it to the blocks. The convention this site uses:
<html data-zui-theme="acme" class="dark" data-zui-density="compact">
/* Each theme block rescoped to the attribute; the light block also covers
a light island inside a dark page, the dark block a dark island */
html[data-zui-theme="acme"],
html[data-zui-theme="acme"] .zui-artboard-light { /* the .theme-acme values */ }
html[data-zui-theme="acme"].dark,
html[data-zui-theme="acme"] .dark { /* the .dark .theme-acme values */ }
data-zui-theme="<id>"on<html>names the theme; the value is the theme's id, the same word as its class..darkon<html>(or an ancestor) is the colour mode, independent of the theme.data-zui-densityis the density preset, independent of both.
Set the attribute before first paint (an inline script reading
localStorage) so the first render is already themed; this site's
plugins/zui-theme.ts is the reference. The attribute names are a
convention of the docs site and its tooling, not something the packages
read: @zuilib/tokens ships data-zui-density and the .dark class, and
everything else is CSS you own.
Beyond variables: data-slot
When a variable is not enough, every component root and part carries a
data-slot attribute, and state is exposed as Headless UI data attributes
(data-checked, data-open, data-disabled, data-invalid, data-focus)
or data-state. Target them from plain CSS; the names are part of the
public API and listed on each component page under "Slots".
/* Uppercase primary buttons in the marketing block */
.marketing [data-slot="button"][data-tone="primary"] {
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Thicker track on checked switches */
[data-slot="switch"][data-checked] {
box-shadow: inset 0 0 0 2px var(--primary-foreground);
}
/* Option rows in every Select and Combobox */
[data-slot="select-option"][data-focus],
[data-slot="combobox-option"][data-focus] {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
The className prop is merged last through cn() (clsx + tailwind-merge),
so a Tailwind utility on a component overrides the same property from the
component's own classes; use it for one-off layout (w-full, mt-4), the
tokens and slots for the brand.
Checklist
- Import one stylesheet entry, then your
brand.css(Getting started). - Set the colour pairs on
:rootand.dark. Keep each-foregroundreadable on its surface and each-textreadable on--background. - Pick
--radius,--font-sans, and the control heights. - Scope with a class instead of
:rootfor a tenant or an embed; remember portalled panels. - Reach for
[data-slot]CSS only for what no token expresses.