Dark mode
Dark mode is on from the first line. Use a role color and both themes are correct for free, with no .dark overrides and no duplicated styles.
Zazz ships dark mode by default. :root declares color-scheme: light dark, and every theme role resolves through light-dark(). Use a role (bg-background, text-foreground, bg-card, text-muted-foreground) and it's right in both themes automatically. There are no parallel dark styles to write or keep in sync.
<!-- Correct in light and dark. Nothing else to do. -->
<section class="bg-background text-foreground p-lg">
<h2 class="text-h3">Works in both themes</h2>
<p class="text-muted-foreground">Because the roles swap, this does too.</p>
</section>How a role resolves
Each role is defined once with a light value and a dark value, and the browser picks based on the active color-scheme:
/* from _variables.css */
--background: light-dark(var(--white), var(--neutral-900));
--foreground: light-dark(var(--neutral-900), var(--white));
--muted-foreground: light-dark(var(--shade-600), var(--tint-600));That's why the rule is: use roles, not raw scales. A role like --primary swaps; a fixed scale step like --primary-600 does not. Reach for scales only when you specifically want a color that stays put across themes.
Three ways the scheme is decided
1. Follow the system (default)
By default, the page follows the operating system's light/dark setting. Nothing required beyond the color-scheme meta tag in your <head>, which paints the right canvas, scrollbars, and form controls before your CSS even loads.
<meta name="color-scheme" content="light dark" />2. Force a theme with .dark
To pin dark mode regardless of system preference, add the .dark class, usually on <html>. It re-declares the role tokens and sets color-scheme: dark so native UI follows:
<html class="dark"></html>3. Inverted surfaces
Popovers and menus render dark-on-light by default (a common design touch) via a container style query (@container style(--use-inverted-popovers: true)). To opt a subtree out, flip the flag or use the data-use-inverted-menu="false" attribute on the popover:
:root {
--use-inverted-popovers: false;
}<div popover data-use-inverted-menu="false">…</div>Don't hand-write dark overrides
If a value is driven by a role token, dark mode is already handled. Writing .dark .my-thing { … } to recolor something that uses roles is redundant and will drift. Let the tokens do it.
/* Don't. The role already swaps. */
.dark .panel {
background: var(--neutral-900);
}
/* Do. One declaration, correct in both themes. */
.panel {
background: var(--card);
}Building a theme toggle
For a user-facing switch, keep the CSS defaulting to the system preference and override only when the user has chosen, so the page is still correct if your script never runs.
- Update the
<meta name="color-scheme">content to the chosen value (light darkfor system, orlight/darkto pin one), and toggle the.darkclass to match. - Persist the choice in
localStorage. - Apply it from an inline, non-deferred script at the top of
<head>so there's no flash before the rest of the page loads.
<meta name="color-scheme" content="light dark" />
<script>
{
const choice = localStorage.getItem("color-scheme");
if (choice) {
document.querySelector('meta[name="color-scheme"]').content = choice;
document.documentElement.classList.toggle("dark", choice === "dark");
}
}
</script>Offer two states, system and its opposite, not three. A pinned choice should stay pinned even when the OS setting later changes, and a three-way "system / light / dark" control always has two options that look identical, which reads as a broken toggle.
Forcing one section light or dark
Set color-scheme on any element to lock it, handy for a code block or media player that should stay dark on a light page:
pre {
color-scheme: dark;
}Browser support
color-scheme is Baseline widely available (since 2022); browsers without it simply render light mode. light-dark() is Baseline newly available: Chrome/Edge 123, Firefox 120, Safari 17.5 (2024). If you need to support browsers older than that, define light/dark values as separate custom properties and switch them with a prefers-color-scheme media query, then layer light-dark() on top with @supports.
Responsive design
Zazz is responsive by default. Fluid type and spacing scale on their own, and responsive prefixes read one centralized set of breakpoints that the body container publishes off the page width.
Colors
Color in Zazz is role-first. Pick a role like primary or muted-foreground and light/dark swap for free; drop to a numeric scale only when a role can't express it.