Skip to content
Nizivo UI

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:

AttributeEffect
noneLight. 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

Card tone="inverse" inside the dark scope: light again.
Card tone="inverse" inside the light scope: dark.

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 dark class, which Fumadocs' styles key on, written by next-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.

On this page