Dark mode in Next.js without the flash of the wrong theme
Quick answer
A dark theme in Next.js needs three things, and CSS variables are only the first one:
- Define the palette per theme — one
[data-theme='dark']block, one[data-theme='light']block, with the tokens redefined in each. - Resolve the theme before the first paint — a small synchronous
<script>in_documentthat readslocalStorageandprefers-color-schemeand setsdata-themeon<html>. AuseEffectruns too late: the wrong palette is already on screen. - Set
color-scheme— otherwise scrollbars,<select>boxes and the default canvas stay light on a dark page.
The part almost every tutorial gets wrong (mine included, for three years) is the second one. Registering a matchMedia change listener is not the same as reading the preference: the listener only fires if the visitor flips their OS theme while the tab is open.
What I shipped, and why it never worked
The original version of this post described exactly what this site was running: CSS variables, data-theme="light" hardcoded in _document, and a hook subscribed to prefers-color-scheme. It had two defects that cancelled each other out well enough that I did not notice for three years.
The first is in the hook. It called mediaQuery.addEventListener('change', handler) and nothing else — it never read mediaQuery.matches. change fires when the preference changes, not when you subscribe, so a visitor who had dark mode enabled before opening the page got the light palette and kept it. The only way to see the dark theme was to toggle the OS setting with the tab already open, which is exactly what I did every time I "tested" it.
The second is worse. The hook was mounted by a single page — the settings dialog — so on every other route data-theme never moved from the "light" written into _document. The dark palette was not just hard to reach, it effectively never rendered. When I finally applied it site-wide, a window surface that had been sitting there since 2023 turned out to be painting white on white, because it used a theme-independent --white-color token that nobody had ever seen in dark mode.
Meanwhile globals.css carried this:
That rule reads the operating system directly, while the palette on the page came from data-theme. On Next.js 15.5 in August 2026, with the OS set to dark, the page reported data-theme="light" and color-scheme: dark at the same time: light background, light text tokens, and dark scrollbars, because the browser had been told the page was dark and the CSS said otherwise.
1 — CSS variables per theme
This part of the original post was fine and has not changed. Colours live as raw channel triplets so they can be reused with an alpha value, and each theme redefines them:
The trap is the :root block. Anything that stays there is theme-independent by definition, and a token like --white-color used as a surface colour will still be white under [data-theme='dark']. If a value names a colour rather than a role, it does not belong in a themed component.
2 — color-scheme, the property that fixes the browser's own UI
color-scheme is what tells the browser which palette to use for the parts of the page it renders: scrollbars, checkboxes, radio buttons, <select> dropdowns, spellcheck underlines, and the default canvas colour before your CSS loads. It has been Baseline — available in every major engine — since January 2022, and it is missing from most dark-theme tutorials, including the first version of this one.
Two details matter more than the property itself:
Declare it inside the [data-theme] blocks, not in a media query. A media query follows the operating system. Your palette follows whatever theme is applied, which after a toggle is not the same thing. Bind them to the same selector and they cannot drift apart.
It also fixes the pre-paint flash colour. With color-scheme: dark, the browser paints its default canvas dark instead of white, so even the frame before your stylesheet applies is the right colour.
3 — Resolve the theme before the first paint
The server cannot know the visitor's preference — there is no request header for prefers-color-scheme — so the HTML has to ship with a default and be corrected in the browser. The correction has to happen before the body is painted, which rules out useEffect.
A synchronous script in the head does it. It is parser-blocking, which is normally something to avoid, but this is a handful of bytes and it is the entire point:
Notes on the details, because each one is there for a reason:
data-theme="light"stays in the markup. It is the fallback for a visitor with JavaScript disabled, and the script overwrites it before anything is painted.- The stored value is checked first, so an explicit choice outranks the OS. A toggle that the next OS change silently undoes is not a toggle.
try/catcharoundlocalStorage. In private mode and behind some extensions, reading it throws. Without the guard, the whole script dies and every visitor gets the light theme.- It goes in
_document, not_app._documentrenders on the server only and is never hydrated, so mutatingdocumentElementfrom it causes no hydration mismatch.
Putting it in the head is also what makes it independent of your component tree. That was the real failure in my case: the theme was applied by a hook that only one page mounted. In the head it runs on every route, regardless of what renders.
4 — The hook: read the initial value, persist the choice
The hook is still needed — for the toggle, and to follow the OS while the tab is open — but it must read matches on mount rather than waiting for a change event:
The change handler deliberately does nothing once a choice has been stored. Without that check, a visitor who picked light would be flipped back to dark the next time their laptop switched to night mode — the toggle would look broken rather than overridden.
toggleTheme uses the functional form of setTheme instead of reading theme from the closure, so it does not need theme in its dependency list and cannot act on a stale value.
5 — Wiring the toggle
Nothing special left at this point. The component reads the current theme for its label and calls toggleTheme:
theme is null on the first render — the value is only resolved inside the effect, because reading localStorage or matchMedia during render would make the server and client markup disagree and produce a hydration error. Label the control from data-theme on the document if you need the text to be correct in the server HTML too.
light-dark(), and whether it is worth it
light-dark() puts both values in one declaration and picks between them based on the element's used colour scheme:
It became Baseline in May 2024, so it is usable today with a fallback, and it removes a genuine annoyance: the two-block structure where every token has to be declared twice in two distant places, and adding one means remembering to add it in both.
I have not migrated this site to it, and the reason is not browser support. light-dark() resolves against color-scheme, so it only handles the light/dark pair — the moment you want a third theme, or a per-section override, you are back to redefining variables under a selector. With a token set already split across two blocks, the rewrite buys tidier CSS and nothing a visitor can see. On a new project I would start with it.
The caveat nobody mentions: theme-color
<meta name="theme-color"> colours the browser chrome on mobile, and it accepts a media attribute:
That media attribute reads the OS, and a <meta> element cannot read localStorage. So a visitor who overrides the theme gets a page in one palette and browser chrome in the other. Swapping the content attribute from the toggle handler is the only way to keep them in sync, and it is worth deciding whether you care before writing that code.
How to check it actually works
Three checks, in the browser console, with the OS set to dark:
The second one is the check that caught the bug in this site: data-theme said light while colorScheme said dark. If those two ever disagree, the palette and the browser UI are reading different sources.
All the code in my github repository, if you like this project, or if the content has helped you in any way you can reward me with a ⭐️, Thanks!
