Themes
Pick a look below, or from the picker in the navbar, and this whole site
changes with it: colours, type, spacing, control sizes, corners, shadows
and motion. Each theme is one block of CSS that overrides the ZUI tokens,
applied to <html>. The selected block is printed beside the cards, so
what you read is exactly what is running. Every theme has a light and a
dark mode; the toggle next to the picker flips between them.
Two of the looks are ours. The other four are products you already use,
rebuilt from each product’s published palette and metrics, so you can see
your components under the app they will live in before writing a line of
theme CSS. Every shipped theme passes zui theme check with zero errors,
so each one is a working example of an AA-clean override file.
Spec sheet
The site’s own look. Green ink on warm yellow paper with a red accent, every radius 0 — pill badges and buttons included — no shadows, quick motion, a regular type scale and a paper sidebar. Dark mode inverts the paper to a deep green and lifts the ink to cream.
.theme-spec {
/* Type */
--font-sans: "Instrument Sans", "Avenir Next", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-base-line-height: 1.6;
--text-lg: 1.125rem;
--text-xl: 1.35rem;
--text-2xl: 2rem;
--text-3xl: 2.75rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 600;
/* Density */
--spacing: 0.25rem;
--control-height-sm: 2rem;
--control-height-md: 2.5rem;
--control-height-lg: 2.75rem;
--control-padding-x-sm: 0.6rem;
--control-padding-x-md: 0.75rem;
--control-padding-x-lg: 1rem;
--button-padding-x-sm: 0.75rem;
--button-padding-x-md: 1rem;
--button-padding-x-lg: 1.5rem;
/* Shape and depth */
--radius: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-button: 0;
--shadow-1: none;
--shadow-2: none;
--shadow-3: none;
--shadow-card: none;
--shadow-4: 0 0 0 1px rgba(11, 51, 36, 0.18);
--shadow-glow: none;
/* Motion */
--duration-fast: 100ms;
--duration-normal: 150ms;
--duration-slow: 220ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
/* Colour */
--background: #fbf7e9;
--foreground: #0b3324;
--card: #fffdf4;
--card-foreground: #0b3324;
--popover: #fffdf4;
--popover-foreground: #0b3324;
--border: rgba(11, 51, 36, 0.18);
--input: rgba(11, 51, 36, 0.3);
--ring: #008751;
--muted: #f3ecd1;
--muted-foreground: #485a4f;
--primary: #007a48;
--primary-foreground: #fbf7e9;
--primary-text: #006b40;
--secondary: #f3ecd1;
--secondary-foreground: #0b3324;
--accent: #fcd116;
--accent-foreground: #0b3324;
--danger: #d8102a;
--danger-foreground: #fbf7e9;
--success: #008751;
--success-foreground: #ffffff;
--warning: #fcd116;
--warning-foreground: #0b3324;
--gradient-primary: linear-gradient(90deg, #008751 0 50%, #fcd116 50% 100%);
--gradient-hero: linear-gradient(90deg, #008751 0 33%, #fcd116 33% 66%, #e8112d 66% 100%);
--gradient-subtle: linear-gradient(180deg, #fbf7e9, #f3ecd1);
--sidebar: #fbf7e9;
--sidebar-foreground: #0b3324;
--sidebar-primary: #007a48;
--sidebar-primary-foreground: #fbf7e9;
--sidebar-accent: #f3ecd1;
--sidebar-accent-foreground: #0b3324;
--sidebar-border: rgba(11, 51, 36, 0.18);
}
.dark .theme-spec {
--background: #06211a;
--foreground: #f5e9bd;
--card: #0d3127;
--card-foreground: #f5e9bd;
--popover: #0d3127;
--popover-foreground: #f5e9bd;
--border: rgba(245, 233, 189, 0.18);
--input: rgba(245, 233, 189, 0.3);
--ring: #2fd07f;
--muted: #0d3127;
--muted-foreground: #9db5a2;
--primary: #2fd07f;
--primary-foreground: #06211a;
--primary-text: #6fe3a6;
--secondary: #0d3127;
--secondary-foreground: #f5e9bd;
--accent: #184030;
--accent-foreground: #f5e9bd;
--danger: #ff5468;
--danger-foreground: #06211a;
--success: #2fd07f;
--success-foreground: #06211a;
--shadow-4: 0 0 0 1px rgba(245, 233, 189, 0.18);
--gradient-subtle: linear-gradient(180deg, #06211a, #0d3127);
--sidebar: #06211a;
--sidebar-foreground: #f5e9bd;
--sidebar-primary: #2fd07f;
--sidebar-primary-foreground: #06211a;
--sidebar-accent: #0d3127;
--sidebar-accent-foreground: #f5e9bd;
--sidebar-border: rgba(245, 233, 189, 0.18);
}
How a theme works
Components read token names (--primary, --radius, --spacing); a
theme gives those names values. Redeclare the values and every component
follows. A theme is a selector plus a set of declarations:
.theme-teal {
--primary: #0f766e;
--primary-foreground: #ffffff;
--radius: 0.25rem;
--font-sans: "Inter", system-ui, sans-serif;
--control-height-md: 2.25rem;
}
.dark .theme-teal {
--primary: #2dd4bf;
--primary-foreground: #042f2e;
}
Where you put the selector decides its reach. On :root or <html> it
themes the whole app; on a class it themes that subtree, which is how a
panel, a preview or an embedded editor can carry its own brand inside a
host (component theming shows this). Dark
mode is the same mechanism: a second set of values under a .dark
ancestor, repeating only what changes. Anything a theme leaves out keeps
the shipped default, so a theme that changes three colours is three
lines.
Two rules keep every theme readable:
- Name the role.
--primaryis the product's principal colour, whatever it is: blue for Foundry, orange for HubSpot, purple for Stripe. - Foregrounds travel with their backgrounds.
--primary-foregroundexists so a theme can make--primaryas dark or as pale as it likes and the text on it stays legible.
What the themes change
| Axis | Tokens | Across the six themes |
|---|---|---|
| Colour | Semantic pairs, surfaces, gradients, sidebar | Spec sheet is green ink on yellow paper; HubSpot pairs an orange action with a navy chrome; Stripe leans on a purple ring; Foundry sets Blueprint's intent blue against a dark platform rail |
| Type | --font-sans, --font-mono, --text-*, --font-weight-* | Microsoft 365, Stripe and Foundry run at 14px; Spec sheet keeps a full 16px scale |
| Spacing | --spacing | One number. Every gap, padding and table cell derives from it, so lifting it a quarter makes the whole app breathe |
| Density | --control-height-*, --control-padding-x-*, --button-padding-x-* | A medium control is 1.75rem in Stripe, 1.875rem in Foundry, 2rem in Microsoft 365, 2.5rem in HubSpot |
| Shape | --radius, --radius-sm … --radius-xl | Spec sheet sets every radius to 0; Foundry stops at 2px, HubSpot at 3px, Microsoft 365 at 4px. --radius is what every rounded-* utility derives from, with the steps for plain-CSS readers |
| Depth | --shadow-1 … --shadow-4, --shadow-card, --shadow-glow | none for Spec sheet; Stripe and Foundry layer a ring under a drop; Microsoft 365 uses the Fluent ramp |
| Motion | --duration-*, --ease-standard, --ease-decelerate | Fluent's easy-ease for Microsoft 365, Blueprint's quick 100ms ease for Foundry |
The structure of the product stays the same across all of them. A new
visual identity costs a few dozen lines of CSS. Start from
theme-template.css, which lists every
token with its default, or npx @zuilib/cli theme init, and run
npx @zuilib/cli theme check brand.css before merging: it catches typos,
values that do not parse, colours set in one mode only, text, tint,
cross-surface and border pairs below their WCAG bar and overrides that
change nothing, so a theme fails a CI build rather than a design review;
zui theme diff old.css new.css prints what a revision changed
(theme validation).