Skip to content

A dark mode toggle that works on a machine already set to dark

prefers-color-scheme and a .dark class fight over specificity and order. One extra :not() makes an explicit choice win in both directions, and one inline script kills the flash.

Marker5 min readships at docs/solutions/marker/class-and-system-dark-mode-that-both-work.md

Tags: dark-mode · css · accessibility · nextjs · theming

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:

OSClassResultWhy
lightnonelightonly :root matches
darknonedark:root:not(.light) matches
darklightlight:not(.light) excludes it; nothing else matches
lightdarkdark: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/catch because localStorage throws in some privacy modes, and an exception here blocks the paint you were trying to protect.
  • suppressHydrationWarning on <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 set color-scheme. Otherwise the library writes an inline color-scheme style that overrides yours.
  • Rebind Tailwind's dark: variant to the class. In Tailwind v4 dark: reads prefers-color-scheme only, so a user who picked light on a dark machine gets light tokens with dark: 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.