Build · Components
Dark mode without the flash

Dark mode without the flash

A theme switch is two lines of CSS and one long tail of things that go wrong: the page flashes light before it goes dark, the choice does not follow the system, it does not survive a reload, every element animates its colour at a different speed, and the browser tab icon stops matching. Paco Coursey’s exists to handle that tail, and its README lists the problems it solves as features: “Perfect dark mode in 2 lines of code,” system setting through prefers-color-scheme, themed browser UI through color-scheme, “No flash on load (both SSR and SSG),” sync across tabs and windows, disabling flashing when changing themes, forcing a page to a theme, a class or data attribute selector, and a useTheme hook. Paco’s own blurb for it is “Perfect dark mode in Next.js apps.”

The documented defaults say what the author thinks a theme should do when you say nothing. defaultTheme is ‘system’ (earlier versions defaulted to light), enableSystem is true, and if you turn enableSystem off the default falls back to light. enableColorScheme is true, so native inputs and buttons follow. The attribute is ‘data-theme’ on the html element, though it accepts class or any data-* attribute. storageKey is ‘theme’ in localStorage. themes is [‘light’, ‘dark’], and passing your own list overrides it, because the library supports any number of themes and is, in its words, “completely CSS independent.” disableTransitionOnChange is false. forcedTheme pins a page without touching the saved setting, and the README says that if it is set you should disable your switching UI. useTheme gives back theme, setTheme, resolvedTheme (what ‘system’ resolved to), systemTheme, and themes with ‘system’ appended.

The no-flash part is a script injected before paint, and two caveats follow from it. Because the server cannot know the theme, values from useTheme are undefined until the client mounts, so rendering UI from the theme before mounting produces a hydration mismatch; the README suggests a skeleton until then, to avoid layout shift, and tells you to add suppressHydrationWarning to html because next-themes edits that element. And Cloudflare’s Rocket Loader breaks the injected script, which is what the scriptProps and nonce options exist for.

The disableTransitionOnChange prop links to a 2020 post of Paco’s, Disable transitions on theme toggle. The instinct when building a switch is to transition every element’s colours so the change feels smooth. He argues the opposite: “Adding a CSS transition to every element negatively impacts rendering performance, and it also won’t work for images, icons, and CSS properties that don’t support transitions.” Elements with different transition durations would change at different rates and feel inconsistent. So he does the reverse: inject a stylesheet that sets transition: none !important on everything (with manual browser prefixes, since the CSS is not preprocessed), toggle the theme, force a repaint, and remove the stylesheet. The forcing is the subtle part. requestAnimationFrame seemed to work and then did not; reading window.getComputedStyle(css).opacity works on every major browser “because it forcibly applies all active stylesheets.” He credits Guillermo for the idea.

Dark mode reaches beyond your page into the browser chrome, and in 2023 Paco documented one corner of that in Safari favicon showing white background on dark mode. As recently as Safari 16.4, a favicon that fails Safari’s contrast requirements is shown on a white background in dark mode. He could not find out what the requirements are: matching or exceeding AA 4.5:1 or AAA 7:1 against both #000 and #282828, Safari’s default dark tab bar, produced no consistent behaviour. The fix is to make the icon’s primary colour brighter, reduce white space, or add a border, and then fight an aggressive favicon cache. For Linear he changed the favicon from #6E79D6 to #8299FF and it fixed the issue, showing the same favicon in both light and dark mode.

That these posts are short and specific is not an accident. In Redesign 2021 Paco describes stripping his own site to documents and links: “Instead of adding as many animations, features, and case studies as possible, this iteration reflects my values of performance, simplicity, and craft.” He admits the pull of novelty, the feeling that every page must use some new interaction, and that landing on a collection of documents felt like a step backward, taken in pursuit of better maintenance. The site is built with Next.js with JavaScript disabled, because “you don’t need it to read documents.” The same temperament produces a theme library whose whole pitch is that you add two lines and stop thinking about it.

What to carry over is a way of seeing the problem. A theme switch is not a colour change; it is a state that must be known before first paint, must follow the operating system unless told otherwise, must persist and sync, must not cause a hydration mismatch, must not animate element by element, and must reach the favicon. Each of those is a small decision with a documented default, and the person who wrote them down chose, in every case, the quiet option.