Theming
Light, dark and inverse surfaces, nested scopes, and rebranding through tokens.
How themes work
Every semantic colour is one light-dark() value, declared once on :root. Which side applies depends
on the element's color-scheme, which data-nz-theme sets:
| Attribute | Effect |
|---|---|
| none | Light. Nizivo never follows the OS unless you opt in. |
data-nz-theme="system" | Follows the operating system (prefers-color-scheme). |
data-nz-theme="light" | Light, whatever the OS says. |
data-nz-theme="dark" | Dark, whatever the OS says. |
The attribute works on any element, so a subtree can switch theme. Scopes nest:
data-nz-theme="light"
Every token follows the nearest scope
Order #1040 Paid
data-nz-theme="dark", nested
Every token follows the nearest scope
Order #1040 Paid
Inverse surfaces
A Tooltip or a tone="inverse" Card renders data-nz-scheme="inverse", which sets
color-scheme to the opposite of the surrounding theme. Every token inside resolves in that scheme, so
there is no separate "on-inverse" token family. An inverse surface inside another inverse surface stays
inverse; it does not flip back.
Remember the reader's choice
ThemeScript (@nizivo/react/theme-script) restores a saved theme before first paint. Render it in
<head>; it runs before <body> exists. It reads localStorage key nz-theme and, when the value is
light, dark or system, sets data-nz-theme on <html> to it, replacing the default your server
rendered. Anything else, nothing stored, or storage the browser blocks leaves the attribute as it
was, and the script never throws. It sets system as it is, and CSS follows the operating system
from there. It also sets data-nz-platform (apple or other), so Kbd spells mod as ⌘ or Ctrl
from the first frame. Your switch writes the same key and the same attribute:
localStorage.setItem('nz-theme', theme); // 'light' | 'dark' | 'system'
document.documentElement.setAttribute('data-nz-theme', theme);The script has no options, so one published hash covers every app under a strict CSP; the
Next.js page has the hash and the nonce. An app that needs a
different key writes its own <head> script the same way.
This site's theme switch
The theme switch (at the foot of the sidebar on these pages, in the top bar on the home page) is
Fumadocs' own, built on next-themes. It offers Light, Dark and System, stores the choice under
nz-theme, and two things on <html> follow it:
- the
darkclass, which Fumadocs' styles key on, written bynext-themes; data-nz-theme="light"or"dark", which Nizivo's tokens key on, written by the site after hydration.
<html> starts as data-nz-theme="system", and ThemeScript in <head> applies the stored choice
before <body> exists, so the first paint is already in the right theme. After hydration the site
writes the resolved light or dark where ThemeScript wrote system, which paints the same.
next-themes does not write the attribute itself: its script would copy a stored value
ThemeScript ignored (not light, dark or system) onto <html> unchecked, and the site puts such a
value back to system. Fumadocs' colour variables are mapped onto Nizivo's tokens
(--color-fd-background: var(--nz-bg-canvas) and so on), and code highlighting follows the same
light-dark() mechanism, so the docs chrome, the code and every live example change together, with
no flash on load.
Rebrand with tokens
Write your theme in light-dark(), inside the nz.themes layer, and start the file with the layer
order statement that opens every Nizivo stylesheet. Layer order is fixed by the first stylesheet that
names a layer, so with that line your theme beats the base tokens whatever order the files load in;
without it, a theme that loads before tokens.css puts nz.themes first, and the base tokens win:
/* The order every Nizivo stylesheet declares; repeat it before any nz.* rule of your own. */
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
/* Rebrand globally; both themes in one declaration. */
@layer nz.themes {
:root {
--nz-bg-brand: light-dark(#5b21b6, #a78bfa);
--nz-text-on-brand: light-dark(#fff, #1e1b4b);
}
}
/* Rebrand one subtree. */
.marketing {
--nz-radius-md: 999px;
}
/* Restyle one component everywhere through its public hook. */
.nz-card {
--nz-card-padding: var(--nz-space-5);
}Unlayered CSS beats every layer, so a theme written outside any layer needs no order line. With the
Tailwind recipe, Nizivo's layers sit inside Tailwind's components
layer, so write the theme there unlayered.
Keep light-dark() inside custom properties, as above. Written directly on background, border or
box-shadow with literal colours, it takes the wrong scheme in nested scopes on Firefox 129–131 (see
Deviations from Figma).
Reduced motion
prefers-reduced-motion: reduce or data-nz-motion="reduce" on any element swaps the motion tokens to
their reduced values. See Motion.