Skip to main content

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.

ScopeWhereEffect
Global:root (light) and .dark (dark)The whole app, both colour modes
SubtreeAny selector, .brand-x, [data-tenant="acme"], a style attributeOnly 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.

Loading example
Loading example

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).

TokensUsed by
--background / --foregroundPage ground, outline and ghost buttons, fields
--primary / --primary-foreground / --primary-text / --primary-borderPrimary 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, --ringHairlines, 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:

TokenDefaultReads it
--control-height-sm/md/lg2rem / 2.5rem / 3remButton, Input, NativeSelect, Select, Combobox, NumberInput, SearchInput, PinInput
--control-padding-x-sm/md/lg0.75rem / 1rem / 1remFields
--button-padding-x-sm/md/lg0.75rem / 1rem / 1.5remButton
--card-padding-sm/md/lg4 / 6 / 8 × --spacingCard
--table-cell-padding-x/y-sm/mdTable
--drawer-size-sm/md/lg20rem / 28rem / 40remDrawer
--container-width-*, --container-paddingContainer
--spacing0.25remThe 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.
  • .dark on <html> (or an ancestor) is the colour mode, independent of the theme.
  • data-zui-density is 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

  1. Import one stylesheet entry, then your brand.css (Getting started).
  2. Set the colour pairs on :root and .dark. Keep each -foreground readable on its surface and each -text readable on --background.
  3. Pick --radius, --font-sans, and the control heights.
  4. Scope with a class instead of :root for a tenant or an embed; remember portalled panels.
  5. Reach for [data-slot] CSS only for what no token expresses.