Docs
Customization and theming
Tokens on :root, hooks on a subtree, utilities on an element, and how dark mode works.
Zazz has three override surfaces, from widest to narrowest. Pick the narrowest that does the job, and never edit the kit's own files to get a look.
1. Tokens on :root
The kit's colors, type, spacing, radii, and shadows are custom properties on :root. Redefine it and everything that reads it follows. The tokens page lists them all with their resolved light and dark values.
@layer overrides {
:root {
--color-primary: light-dark(oklch(0.35 0.12 260), oklch(0.85 0.1 260));
--color-primary-foreground: light-dark(white, oklch(0.2 0.05 260));
--radius-multiplier: 0;
--font-family-heading: "Fraunces", var(--font-sans-fallback);
--spacing: 0.3rem;
}
}Write the block in @layer overrides (or import your stylesheet into it) so it outranks the kit's variables layer. Color roles are light-dark() pairs; keep them that way and dark mode keeps working with no extra rules.
The text-selection highlight is the --color-selection / --color-selection-foreground pair, which defaults to the primary pair. Unlike the other roles, it only takes effect on :root: the kit styles ::selection there once and every selection inherits it, so the pair set on a subtree changes nothing.
2. Hooks on a subtree
Every primitive reads --ui-<primitive>-<utility> hooks that inherit, so a hook set on a region themes every instance inside it while each keeps its own states. Where a primitive has a state, it also reads a state hook.
<nav style="--ui-button-px: 2; --ui-button-rounded: var(--radius-full); --ui-button-bg--hover: var(--color-muted)">
…every button in here…
</nav>The widest lever is the --ui-field-* family: inputs, selects, and textareas read it directly, and button, tabs, checkbox, radio, badge, menu, and command metrics default to it, so --ui-field-h: 2.75rem retunes a whole form row. Each primitive's hooks, with defaults, are on its API page.
3. Utilities on one element
A style utility is the escape hatch for one element: style="--bg: var(--color-muted); --rounded: var(--radius-lg)". On a primitive it flattens the states it touches (--bg on a button is that color on hover too), so add the state tier or use the hook when the states matter.
Dark mode
:root declares color-scheme: light dark, and every color role is a light-dark() pair, so the page follows the system scheme with no second stylesheet. The theme-dark or theme-light switch on any element (data-ui="theme-dark") pins a scheme for that subtree by setting color-scheme; scopes nest, so a dark sidebar can hold a light card.
The head's theme script reads localStorage.theme and adds the matching switch to <html>'s data-ui before first paint. A toggle writes the same two things, keeping any other tokens on <html>:
function setTheme(value) {
const root = document.documentElement;
const tokens = (root.dataset.ui ?? "").split(/\s+/).filter((t) => t && !t.startsWith("theme-"));
if (value === "light" || value === "dark") {
tokens.push(`theme-${value}`);
localStorage.setItem("theme", value);
} else {
localStorage.removeItem("theme"); // follow the system
}
root.dataset.ui = tokens.join(" ");
}Roles are deliberately unregistered properties so they re-resolve per element inside a pinned subtree. Shadows tint through --color-shadow globally or --shadow-hue on a subtree.
A brand block
A complete brand is a few dozen lines: the role colors, the scales they draw from, type, radius, and a handful of hooks.
@layer overrides {
:root {
/* neutrals and brand scales */
--color-neutral-900: oklch(0.2 0.01 60);
--color-secondary-500: oklch(0.85 0.17 90);
/* roles */
--color-background: light-dark(oklch(0.98 0.005 80), var(--color-neutral-900));
--color-primary: light-dark(var(--color-neutral-900), oklch(0.98 0.005 80));
--color-secondary: var(--color-secondary-500);
/* shape and type */
--radius-multiplier: 0;
--font-family-heading: "Fraunces", var(--font-sans-fallback);
/* primitives */
--ui-field-h: 2.75rem;
--ui-button-font-weight: 600;
--ui-card-border-width: 1px;
}
}Fonts
The family tokens (--font-family-body, --font-family-heading, --font-family-mono) are system stacks by default; the kit's CSS loads no font files. To use your own fonts, load them in the head and point the tokens at them, keeping the system stack as the fallback:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap">
<style>
@layer overrides {
:root {
--font-family-body: "Inter", var(--font-sans-fallback);
--font-family-heading: var(--font-family-body);
}
}
</style>buildHead() still writes a Geist block by default. It loads Geist without applying it, so set the tokens as above to use it, or pass fontDisplay: false (init --no-fonts in the CLI) to skip the request.