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.
| Axis | Set on <html> as | Values |
|---|---|---|
| Palette | data-theme | ember · sapphire · verdant · plum · slate |
| Mode | class | dark, 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 */
@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:
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 — 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 whatsuppressHydrationWarningon<html>is telling React to expect. - The whole body is in a
try. Private-mode browsers can throw onlocalStorage, 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.
// 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.
"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() | Type | What it is |
|---|---|---|
| theme | "light" | "dark" | "system" | The stored preference. |
| resolvedTheme | "light" | "dark" | What "system" currently means. Use this to render, not `theme`. |
| setTheme | (mode) => void | Sets and persists the mode. |
| palette | string | The active palette, as written to data-theme. |
| setPalette | (name) => void | Sets and persists it. |
| palettes | readonly string[] | Everything the provider was told about, for building a menu. |
It is not required
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.// 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.
{/* 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.