// preview in
saved for every page

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.

minimal · light · #3fa34d

Storage

Used38.2 GB of 50 GB
x

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:

tokens.css (excerpt)
: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.

modedata-modeResult
left outnoneInherits from the parent; at the root, follows the system
"system"systemFollows the system, even inside a light or dark parent
"light"lightAlways light
"dark"darkAlways dark
TIP

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.

app/providers.tsx
<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.

app/layout.tsx
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:

mode-toggle.tsx
"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);
}
CAREFUL

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:

index.html
<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:

app/layout.tsx
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 no mode
  • 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-color matches the skin's surface in both modes
Was this page helpful? · Edit this page on GitHub