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

Loading example
Loading example

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

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

TokenDefaultReads it
--control-height-sm/md/lg2rem / 2.5rem / 3remButton, Input, Select, Listbox, 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/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

  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.