Dark mode

Two independent axes, one class on the root, and the blocking script that stops the wrong theme flashing.

Two axes

Palette and mode are independent, and each has its own control. One control that cycled both would make some combinations unreachable.

AxisSet on <html> asValues
Palettedata-themeember · sapphire · verdant · plum · slate
Modeclassdark, or absent for light

Five palettes times two modes is ten themes, and every component is checked in all of them. Nothing in a component file needs to know which one is active — it reads token names, and the root decides what they resolve to.

The dark variant

A class on the root rather than a media query, so the choice can be a preference and not only an OS setting.

app/globals.css
/* app/globals.css */
@custom-variant dark (&:where(.dark, .dark *));

/* Which makes this work anywhere under .dark … */
<div className="bg-background dark:ring-white/10" />

The :where() wrapper keeps the variant at zero specificity, so a dark: utility never wins an argument it should have lost on source order. Declare it once and dark: works everywhere.

You will rarely need dark:

Because the tokens already carry both modes, most components contain no dark: classes at all. Reach for it only for something a colour token cannot express — an image's opacity, a gradient stop.

Avoiding the flash

The one part of dark mode that has to happen outside React. A preference read after hydration is read one paint too late.

app/layout.tsx
// app/layout.tsx — in <head>, before the first paint.
import { ThemeProvider, themeScript } from "kipui/theme";

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript() }} />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}
  • themeScript() returns the source to inline. It is synchronous and in <head>, so the browser executes it before painting the body and the correct theme is on the root from the first frame.
  • It writes to documentElement, not to React state. Hydration finds the class and attribute already set and leaves them alone — which is what suppressHydrationWarning on <html> is telling React to expect.
  • The whole body is in a try. Private-mode browsers can throw on localStorage, and a theme preference is not worth a blank page.
  • It defaults to system, so a first-time visitor gets the mode their OS is already in.
  • It also sets color-scheme, so form controls, scrollbars and the space beyond the page match before your CSS loads.

Different keys or defaults

Both take the same four options. They have to agree — the script reads what the provider writes.

app/layout.tsx
// The keys have to match the provider's, or the two disagree
// about what is stored.
themeScript({
  defaultTheme: "light",       // instead of following the OS
  defaultPalette: "ember",
  storageKey: "ui-mode",
  paletteStorageKey: "ui-palette",
});

Switching at runtime

ThemeProvider owns both axes after mount and persists each to localStorage. useTheme is how you drive it.

mode-buttons.tsx
"use client";

import { useTheme } from "kipui/theme";

export function ModeButtons() {
  const { theme, setTheme, resolvedTheme } = useTheme();

  return (
    <div role="group" aria-label="Colour mode">
      {(["light", "dark", "system"] as const).map((mode) => (
        <button
          key={mode}
          onClick={() => setTheme(mode)}
          aria-pressed={theme === mode}
        >
          {mode}
        </button>
      ))}
      {/* "system" resolved to a real mode, for anything that needs to know. */}
      <span>{resolvedTheme}</span>
    </div>
  );
}
From useTheme()TypeWhat it is
theme"light" | "dark" | "system"The stored preference.
resolvedTheme"light" | "dark"What "system" currently means. Use this to render, not `theme`.
setTheme(mode) => voidSets and persists the mode.
palettestringThe active palette, as written to data-theme.
setPalette(name) => voidSets and persists it.
palettesreadonly string[]Everything the provider was told about, for building a menu.

It is not required

The provider moves one class and one attribute on the root. Anything else that moves the same two works identically — next-themes, your own hook, a server-rendered class from a cookie. No component reads the provider, so leaving it out costs you nothing but the switcher.
your-own-switcher.ts
// Already have a switcher? Keep it. Nothing in a component reads
// the provider — they read the class it sets.
document.documentElement.classList.toggle("dark", isDark);
document.documentElement.dataset.theme = "verdant";

Writing dark-safe code

One rule covers almost every case: never name a colour, name its role.

usage.tsx
{/* Wrong — invisible in dark mode. */}
<div className="bg-white text-gray-900 border-gray-200" />

{/* Right — the token already knows what to do in both. */}
<div className="bg-card text-card-foreground border-border" />

{/* Fine — dark: for the rare case a token cannot express. */}
<img className="opacity-90 dark:opacity-75" />

A hard-coded colour is the only way a component in this library breaks in dark mode. If you are reaching for white, black or a numbered grey, there is a token for what you mean — and using it means your component also works in the four palettes you have not tried.