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), 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/components/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/components/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 Listbox 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 buttons, checked states, active tabs, links, focus |
--secondary, --accent, --muted (+ -foreground) | Secondary buttons, hover surfaces, disabled and helper text |
--destructive, --success, --warning, --info (+ -foreground, -text) | Status: alerts, badges, messages, invalid fields |
--card, --popover (+ -foreground) | Card and overlay surfaces |
--border, --input, --ring | Hairlines, field borders, keyboard focus ring |
Shape: one --radius
--radius-sm/md/lg/xl derive from --radius (md is the value, sm two
pixels less, lg four more). Set --radius: 0 for a square brand,
999px for pills, or override an individual step to break the ladder.
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, Select, Listbox, 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/out 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.
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-variant="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 Listbox and Combobox */
[data-slot="listbox-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.