Dark mode
Light, dark or the system's choice, per page or per island. Dark mode is plain CSS, so the first paint is already right.
// you'll learn
- What
mode="light" | "dark" | "system"does, and what inherits it - Why there is no flash on first paint, and how to keep it that way with a saved choice
- How to color the browser chrome with
theme-color
Try it
This island sets mode on its own StyleProvider, whatever the "preview in" bar says. Pick System and change your OS setting: it follows without a reload.
Storage
How it works
Every color token is written with light-dark(): one value for each mode in a single declaration. The browser picks the side from color-scheme, and data-mode sets it:
:root { color-scheme: light dark; }
[data-mode="light"] { color-scheme: light; }
[data-mode="dark"] { color-scheme: dark; }
[data-mode="system"] { color-scheme: light dark; }
:where(:root, [data-style]) {
--cml-surface: light-dark(#ffffff, #09090b);
--cml-fg: light-dark(#09090b, #fafafa);
}color-scheme: light dark means "follow prefers-color-scheme". color-scheme is inherited, so a provider without mode simply takes its parent's, and the page root follows the system with no script at all. Native parts (scrollbars, date pickers, autofill) follow too.
mode | data-mode | Result |
|---|---|---|
| left out | none | Inherits from the parent; at the root, follows the system |
"system" | system | Follows the system, even inside a light or dark parent |
"light" | light | Always light |
"dark" | dark | Always dark |
Dialogs, menus and toasts render in a portal. They carry the provider's mode through React context, so a popup opened from a dark island is dark too.
Pick a default
For most apps, leave mode out or set "system" on the top provider: the page matches the OS from the first byte. Use a fixed mode for areas that must stay one way, such as a dark code panel or a light print preview.
<StyleProvider styleName="minimal" mode="system">
<App />
<StyleProvider styleName="minimal" mode="dark">
<CodePanel />
</StyleProvider>
</StyleProvider>
Save the visitor's choice without a flash
A toggle that stores the choice in localStorage and applies it after hydration paints the wrong mode first. Render the saved mode on the server instead: keep it in a cookie and spread it on <html> with styleScopeProps, which is safe to call in a Server Component.
import { cookies } from "next/headers";
import { styleScopeProps } from "@camaleo/react/style-scope";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const saved = (await cookies()).get("mode")?.value;
const mode = saved === "light" || saved === "dark" ? saved : "system";
return (
<html lang="en" {...styleScopeProps({ styleName: "minimal", mode })}>
<body>{children}</body>
</html>
);
}
The toggle writes the cookie and the attribute; nothing has to re-render:
"use client";
export function setMode(mode: "light" | "dark" | "system") {
document.cookie = `mode=${mode}; path=/; max-age=31536000; samesite=lax`;
document.documentElement.setAttribute("data-mode", mode);
}
Don't give your inner providers a mode if the root owns it: a fixed mode on a nested provider wins over the toggle. And reading cookies() makes the route render per request.
On a static site or a single-page app with no server, the only flash-free option is a tiny inline script in <head> that runs before the first paint:
<script>
try {
document.documentElement.setAttribute("data-mode", localStorage.getItem("mode") || "system");
} catch {}
</script>Color the browser chrome
theme-color tints the address bar and the task switcher on mobile browsers. Give it one value per scheme, using your skin's surface color. In Next, export it from the root layout:
import type { Viewport } from "next";
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#09090b" },
],
};
The media queries follow the system. If visitors can force a mode, the toggle should also set the content of both theme-color tags to the forced mode's color.
Before you ship it
- The top provider uses
"system"or nomode - A saved choice is rendered by the server, or applied by an inline script before paint
- Only areas that must stay one way have a fixed
mode theme-colormatches the skin's surface in both modes