Skip to content

Writing

Dark mode without the flash: theming across Next.js, React Native and mobile web

The flash of the wrong theme is a timing problem, not a styling one. How to make the first paint correct on the server, keep it correct across platforms, and avoid the four traps that make a dark theme look broken.

5 min read

Nearly every dark-mode implementation has the same defect: for one frame, the page is light. The user chose dark, the app knows they chose dark, and it still paints white first.

That is not a CSS problem. It is a question of what the server knew when it wrote the first byte, and once you see it that way the fix is obvious.

Why the flash happens

The usual implementation stores the preference in localStorage. Then:

  1. The server renders

    It cannot read localStorage. It guesses — usually light.

  2. The browser paints

    Light. This is the flash, and it is a real frame the user sees.

  3. JavaScript loads and reads storage

    It finds dark, sets the class, and the page repaints.

tsx
// A Server Component. The theme is known before a byte is written.
import { cookies } from 'next/headers';
 
export default async function RootLayout({ children }) {
  const theme = (await cookies()).get('theme')?.value ?? 'system';
  return (
    <html lang="en" data-theme={theme} suppressHydrationWarning>
      <body>{children}</body>
    </html>
  );
}

Keep localStorage as well — it is useful for cross-tab sync and as a fallback — but the cookie is what makes the first paint correct. That hybrid is exactly what our own @teispace/next-themes package implements, and the no-flash property is the entire reason it exists.

A first-time visitor who has never chosen. There is no cookie, and the server cannot read prefers-color-scheme either — it is a client capability.

Handle it in CSS rather than JavaScript, so it costs nothing:

css
:root { color-scheme: light; --bg: #ffffff; --fg: #1a1a1a; }
 
/* No stored choice: follow the OS, in CSS, with no script involved. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) { color-scheme: dark; --bg: #0a0c0f; --fg: #e6e9ee; }
}
 
/* An explicit choice always wins, in both directions. */
:root[data-theme='dark'] { color-scheme: dark; --bg: #0a0c0f; --fg: #e6e9ee; }
:root[data-theme='light'] { color-scheme: light; --bg: #ffffff; --fg: #1a1a1a; }

Four traps that make a dark theme look wrong

One: inverting instead of designing

A dark theme is not a light theme with the lightness flipped. Pure white text on pure black produces halation — the text appears to vibrate — and pure black surfaces make every shadow invisible.

RoleLight themeDark theme
Background#ffffff#0a0c0f — near-black, not black
Body text#1a1a1a#e6e9ee — off-white, not white
ElevationShadowA lighter surface plus a hairline border
Brand accentOften unchangedUsually needs lightening for contrast
Numbers from a working dark palette. The key move is that neither end is pure.

Two: forgetting color-scheme

One declaration, and it fixes form controls, scrollbars and the native autofill background — all of which stay light otherwise and look conspicuously wrong.

css
:root[data-theme='dark'] { color-scheme: dark; }

Three: assuming a colour that passes in light passes in dark

Contrast is not symmetric. A brand colour that reads well on white can fail on near-black. Every token has to be checked against every surface it can appear on, not against the one it was designed for — a token of ours moved twice for exactly this reason, passing on one surface and failing at 4.45:1 on another.

Four: animating the transition

A colour transition on * when the theme changes looks smooth in a demo and janks on a real page, because it animates paint properties on every element at once. If you want a transition, scope it to a handful of elements — or skip it, which is what most systems that feel fast do.

Cross-platform: one source of truth

The tokens are the same idea in every runtime. Only the delivery differs.

PlatformDeliveryPreference source
WebCSS custom propertiesCookie + prefers-color-scheme
React NativeA theme object in contextuseColorScheme() + stored choice
Mobile web / PWACSS, plus theme-colorSame as web
Generate all three from one token file rather than maintaining three palettes.
tsx
// React Native — the same three states as the web.
const system = useColorScheme();               // 'light' | 'dark' | null
const [stored, setStored] = useStoredTheme();  // 'light' | 'dark' | 'system'
const theme = stored === 'system' ? (system ?? 'light') : stored;

Two mobile-web details that are easy to miss:

html
<!-- The browser chrome should match the page, per scheme. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0c0f" media="(prefers-color-scheme: dark)" />

And give the document an explicit background. A transparent body borrows whatever is behind it, which on an embedded or in-app browser is not what you expect.

Three states, not two

The most common API mistake is a boolean.

ts
type Theme = 'light' | 'dark' | 'system';   // not `isDark: boolean`

"System" is a real, distinct choice — it means keep following my OS, which a boolean cannot represent. A user who picks it and then changes their OS setting expects the app to follow, and with a boolean it will not.

The checklist

  1. Store the preference in a cookie so the server can read it

    Keep localStorage too, for cross-tab sync — but the cookie is what removes the flash.

  2. Handle the no-choice case in CSS, guarded against explicit choices

    Three blocks: system, explicit dark, explicit light.

  3. Set color-scheme, or form controls and scrollbars stay light

    One declaration, and it is the most visible thing people forget.

  4. Design the dark palette rather than inverting the light one

    Near-black and off-white; elevation by surface step and hairline, not shadow.

  5. Re-check every colour against every surface it can appear on

    Contrast is not symmetric between themes.

  6. Model the preference as three states, and generate every platform from one token file

    A boolean cannot express "follow my system".

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.