You support both: the site follows the operating system, and a toggle lets people override it. You test on your laptop, which is set to light. Dark works, light works, ship it.
Then somebody whose machine is set to dark clicks "Light" and nothing happens.
Why it happens
The usual implementation looks like this:
:root { --background: #ffffff; }
.dark { --background: #09090b; }
@media (prefers-color-scheme: dark) {
:root { --background: #09090b; }
}
Two problems are hiding in there.
Specificity. .dark on its own is one class: (0,1,0). :root is one
pseudo-class: (0,1,0) as well. They tie, so document order decides, and the
media query is last, so on a dark machine it wins over .dark. That happens to
be harmless.
There is no light override at all. Nothing in that CSS can express "the
machine says dark but the user asked for light". The media query applies to
:root unconditionally, and adding a .light class changes no rule.
So the toggle appears to work in one direction only, and exclusively for people whose OS is set to light, which is to say, for you.
The fix
Two changes: exempt an explicit light choice from the media query, and make the explicit dark selector outrank it.
/* 1. light is the base */
:root {
color-scheme: light;
--background: #ffffff;
--foreground: #09090b;
}
/* 2. system dark, unless the user explicitly asked for light */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
color-scheme: dark;
--background: #09090b;
--foreground: #fafafa;
}
}
/* 3. explicit dark, after the media query so it wins on a light machine */
:root.dark,
.dark {
color-scheme: dark;
--background: #09090b;
--foreground: #fafafa;
}
Walk the four cases:
| OS | Class | Result | Why |
|---|---|---|---|
| light | none | light | only :root matches |
| dark | none | dark | :root:not(.light) matches |
| dark | light | light | :not(.light) excludes it; nothing else matches |
| light | dark | dark | :root.dark matches |
:root:not(.light) is (0,2,0) and :root.dark is (0,2,0): a tie again, broken
by order, which is why block 3 comes last. The bare .dark in that selector list
is there so the class also themes a subtree, which is what a side-by-side theme
preview needs.
Write literal values in both blocks, not var() references. If block 3 said
--background: var(--canvas-dark), a .dark on a <div> would still inherit
the computed light value from :root for anything not redefined on that same
element. Literals make a subtree swap total.
Set color-scheme in each block. It is what themes form controls,
scrollbars and the canvas the browser paints behind your page. Skipping it gives
you a light scrollbar on a dark page and a white flash during navigation.
Leave all three blocks outside @layer. This is the trap in a Tailwind v4
project, where a stylesheet is mostly layers. An unlayered declaration beats
every layered one no matter where it sits in the file, so these three win over
whatever a framework or a base reset already said about color-scheme. Put
them inside @layer base and you are back to "later wins", and a base block
that ships further down the file than yours will quietly take the mode back.
The one saving grace is specificity: :root is (0,1,0) and beats a bare html
(0,0,1) even inside one layer. Do not rely on it; keep them unlayered, and put
a comment next to them saying why, because this is invisible in review and
there is nothing a type-checker or a linter can catch.
The flash
The remaining bug is the one everybody notices: a white blink before the app
decides it is dark. It happens because the stored preference lives in
localStorage, which JavaScript reads after the first paint.
The only real fix is to set the class before the browser paints, from a blocking inline script in the document head:
// src/app/layout.tsx
const themeScript = `
try {
var stored = localStorage.getItem("theme");
if (stored === "dark" || stored === "light") {
document.documentElement.classList.add(stored);
}
} catch (e) {}
`;
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
}
Four details that matter:
- No stored value means no class, so the media query takes over. Do not write
"light"as a default: that opts everyone out of their system setting. try/catchbecauselocalStoragethrows in some privacy modes, and an exception here blocks the paint you were trying to protect.suppressHydrationWarningon<html>because the script mutates the element the server rendered.- Blocking is the point. Moving this into a component, an effect or a deferred script brings the flash back.
The toggle then writes the same key and toggles the same classes:
function setTheme(next: "light" | "dark" | "system") {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (next === "system") localStorage.removeItem("theme");
else {
root.classList.add(next);
localStorage.setItem("theme", next);
}
}
Three states, not two. A toggle that can only say light or dark permanently opts the user out of following their machine, which is the setting most people actually want.
Or let a library write the script
next-themes ships exactly this: a blocking inline script, a stored choice and
a three-state setTheme. With attribute="class", defaultTheme="system" and
enableSystem it puts light or dark on <html>, which is what the CSS
above keys on. Two settings are worth knowing:
enableColorScheme={false}if your palette blocks already setcolor-scheme. Otherwise the library writes an inlinecolor-schemestyle that overrides yours.- Rebind Tailwind's
dark:variant to the class. In Tailwind v4dark:readsprefers-color-schemeonly, so a user who picked light on a dark machine gets light tokens withdark:exceptions painted over them. Declare the variant once in your stylesheet:
@custom-variant dark {
&:where(.dark, .dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(:root:not(.light), :root:not(.light) *) {
@slot;
}
}
}
The media branch covers a first paint with no class yet, the same way the
:root:not(.light) palette block does.
Testing it
Emulate the OS setting instead of changing it: in Chrome dev tools, Rendering → "Emulate CSS prefers-color-scheme". Then walk all four rows of the table above, and reload on each one: the flash only shows up on a cold load, so a toggle tested without a refresh proves nothing.