All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Dark Mode Done Properly: Tokens, Contrast and the Flash of Wrong Theme

Dark mode CSS done right: kill the flash of wrong theme, build semantic tokens, fix contrast and use light-dark() with prefers-color-scheme. Real code inside.

A real working moment illustrating the theme of an article about dark mode css. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

The theme flash is the bug clients notice and developers don’t. You ship a site with a dark mode toggle, it works perfectly on your machine, and then someone on a slow 4G connection with dark mode enabled hits the homepage and gets a 200ms blast of white before everything snaps dark. They don’t file a bug report. They just think the site feels cheap.

We’ve built dark mode into roughly every project that’s come through SemiColonWeb in the last four years, including the dark variants across the Canvas demo library. The pattern that keeps failing isn’t the CSS. It’s the order of operations in the <head>, and a token layer that was designed for one theme and bolted into two.

This is the version with the trade-offs left in: how to write dark mode CSS that doesn’t duplicate every declaration, why WCAG contrast ratios lie to you in dark mode, and the exact nine lines that kill the flash of wrong theme.

Key Takeaways

  • The flash of wrong theme is fixed by one synchronous inline script in <head> that reads localStorage and sets an attribute on <html> before the first paint. Deferred, third-party or module scripts all run too late.
  • light-dark() plus color-scheme has been Baseline since mid-2024 and removes the duplicated @media (prefers-color-scheme: dark) block entirely. One token declaration, two values.
  • Never invert your primitive palette. Dark mode needs its own semantic mapping: surfaces get lighter with elevation, saturated brand colours need lightness raised and saturation cut, and shadows stop working as depth cues.
  • Pure white text on pure black fails real users. Target off-black around #10131a and off-white around #e8eaed, and check the pair both ways because 4.5:1 in light mode does not guarantee 4.5:1 inverted.
  • A toggle needs three states (system, light, dark) and must suppress CSS transitions during the switch, or every coloured element cross-fades independently and the page looks broken for 300ms.

Killing the flash of wrong theme

The flash happens because your theme preference lives in JavaScript state and your first paint doesn’t wait for JavaScript. The browser parses HTML, builds enough of the render tree, and paints. If the only thing telling it “this user wants dark” is a script that runs after DOMContentLoaded, you get white, then dark. On a fast connection that’s 40ms and barely visible. On a throttled connection with a heavy bundle it’s half a second.

The fix is a blocking inline script placed in <head> before any stylesheet that depends on it:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script>
(function () {
  try {
    var stored = localStorage.getItem('theme'); // 'light' | 'dark' | null
    // null means "follow the system", so we set nothing and let color-scheme decide
    if (stored === 'light' || stored === 'dark') {
      document.documentElement.setAttribute('data-theme', stored);
    }
  } catch (e) {} // Safari private mode throws on localStorage access
})();
</script>
<link rel="stylesheet" href="/css/app.css">

Four things about that snippet matter more than they look.

  • It must be inline. An third-party script, even without defer, costs a connection and a round trip. Inline it’s roughly a millisecond of parse.
  • No defer, no async, no type="module". Modules are deferred by definition. This is the one place in 2026 where a synchronous script in the head is correct.
  • The try/catch is not defensive padding. localStorage throws in Safari private browsing and in cross-origin iframes with storage partitioned. Without the catch, the error kills the script and you get the flash plus a console error.
  • Content Security Policy. If you run a strict CSP, this needs a hash ('sha256-...') or a nonce. Keep the script byte-stable so the hash doesn’t change on every build.

What you should not do: hide the page until JavaScript decides. We’ve inherited sites with html { visibility: hidden } and a class flipped on load. That trades a 50ms flash for a delayed First Contentful Paint and a completely blank page for anyone whose JS fails. It’s the wrong trade. The same reasoning we apply to progressive enhancement generally applies here: the default render has to be usable.

A real working moment that fits an article section about

Token architecture that survives two themes

Here’s the mapping that always breaks. Someone defines --color-white: #fff and --color-gray-900: #111, uses them everywhere, then in dark mode swaps the values so white is dark. Now --color-white is black. Every new developer on the project gets caught by it, and any component that genuinely needed white (a logo lockup, an overlay caption) is now wrong.

Use three tiers and only let components touch the middle one.

  1. Primitives: raw, theme-agnostic, never change. --blue-500: #2563eb. These are facts about colour.
  2. Semantic tokens: the theme layer. --surface-1, --surface-2, --text-primary, --text-muted, --border-subtle, --accent, --accent-contrast. These flip.
  3. Component tokens: optional, scoped. --card-bg: var(--surface-2). Useful when one component needs to deviate without forking the semantic layer.

Name semantic tokens by role, never by appearance. --surface-sunken survives a theme flip. --bg-light-gray does not. If you work in a block theme, the same discipline maps onto palette slugs in theme.json, where a badly named palette entry leaks into editor UI and user-generated class names you can’t change later.

Elevation inverts

In light mode, depth comes from shadow: the card sits above the page because it casts a shadow. On a dark background, rgba(0,0,0,0.15) is invisible. Depth in dark mode comes from lightness. The further an element is from the page, the lighter its surface. So --surface-0 (page) is your darkest value, --surface-1 (card) is a few points lighter, --surface-2 (popover, modal) lighter again, and you add a hairline 1px solid rgba(255,255,255,0.08) border to define the edge. Keep the shadow token too, but reduce its spread and raise its opacity. It’s doing ambient occlusion now, not lift.

How to implement dark mode without duplicating your CSS

The old pattern required dark values in two places: inside @media (prefers-color-scheme: dark) scoped to :root:not([data-theme="light"]), and again for :root[data-theme="dark"]. Two copies of every token, kept in sync by hope.

light-dark() ends that. It resolves against the element’s used color-scheme, so you set the scheme once and every token picks its own branch:

:root {
  /* Both schemes allowed: the OS preference wins by default.
     This also fixes form controls, scrollbars and the canvas background. */
  color-scheme: light dark;

  --surface-0: light-dark(#ffffff, #10131a);
  --surface-1: light-dark(#f6f7f9, #171b24);
  --surface-2: light-dark(#ffffff, #1f242f);
  --text-primary: light-dark(#14171c, #e8eaed);
  --text-muted: light-dark(#5b6472, #9aa4b2);
  --border-subtle: light-dark(rgb(0 0 0 / 0.10), rgb(255 255 255 / 0.10));
  --accent: light-dark(#1d4ed8, #93b4ff);
  --accent-contrast: light-dark(#ffffff, #0b1020);
}

/ The explicit user override from the head script /
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

body { background: var(--surface-0); color: var(--text-primary); }

That’s the whole mechanism. No media query, no duplicated block, and the attribute override is two lines instead of a second palette.

Two caveats worth knowing before you refactor a large codebase.

First, light-dark() resolves at the point of use, not declaration. Declare the token on :root, use it inside a section with color-scheme: dark, and you get the dark branch. That’s genuinely useful for permanently inverted footers or a dark hero inside a light page. It also means you can’t reason about a token’s value without knowing where it lands in the DOM.

Second, you still need @media (prefers-color-scheme: dark) for anything that isn’t a colour value: swapping a background image, choosing a different Mapbox style, or setting theme-color. And note that prefers-color-scheme: no-preference is effectively dead. Every current browser reports light or dark, so don’t branch on it.

If you need to support older engines, keep the media-query version behind @supports not (color: light-dark(#fff, #000)) rather than writing everything twice.

Contrast in dark mode is not light mode inverted

Run #1d4ed8 on white through a contrast checker and you get about 6.9:1. Comfortably passing. Invert it, put that same blue on #10131a, and you’re near 2.3:1. Unreadable. Every saturated brand colour behaves this way, because saturated hues sit in the middle of the lightness range and dark mode moves the background to the bottom of it.

The adjustment is mechanical: raise lightness, cut saturation. A link blue that reads as #1d4ed8 on white typically needs to land around #93b4ff on a dark surface to clear 4.5:1, and the saturation drop matters because fully saturated colours on near-black appear to vibrate.

Avoid pure black and pure white as a pair. #ffffff text on #000000 gives you 21:1, which sounds like a win and reads terribly: the glyphs bloom, an effect people with astigmatism feel acutely. Off-white on off-black lands around 15:1, which is plenty, and it stops your UI looking like a terminal.

On the measurement question, take a position: ship to WCAG 2.2 numbers because that’s what’s enforceable (4.5:1 body text, 3:1 for large text and non-text UI like focus rings and input borders), but sanity-check dark pairs with APCA. WCAG 2’s maths is known to be unkind to light-on-dark, and you’ll find pairs that technically pass at 4.6:1 yet look thin and washed out. APCA isn’t normative, so don’t use it to justify failing an audit. Use it to catch pairs the ratio says are fine but your eyes say aren’t.

One more: focus rings. A ring tuned for a white page frequently disappears on dark surfaces. Token it (--focus-ring: light-dark(#1d4ed8, #93b4ff)) and test keyboard navigation in both modes. We’ve found more broken focus states in dark mode than in any other category of theme bug, including on interactive components where focus is already fragile.

Three states, and the transition that must not animate

A two-state toggle is a bug. If the user’s OS switches to dark at sunset and your site is pinned to light because they once clicked the button, you’ve overridden a system-wide preference permanently. Offer System, Light, Dark, and make System the default by storing null (or removing the key) rather than writing a value.

const root = document.documentElement;

export function setTheme(choice) { // 'system' | 'light' | 'dark'
  if (choice === 'system') {
    localStorage.removeItem('theme');
    root.removeAttribute('data-theme');
  } else {
    localStorage.setItem('theme', choice);
    root.setAttribute('data-theme', choice);
  }
  syncThemeColor();
}

// theme-color does not follow data-theme, so update it by hand
function syncThemeColor() {
  const bg = getComputedStyle(root).getPropertyValue('--surface-0').trim();
  document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
}

// Reflect OS changes while the tab is open, but only in system mode
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
  if (!localStorage.getItem('theme')) syncThemeColor();
});

Now the part people skip. If you have transition: background-color .2s, color .2s anywhere in your stylesheet, flipping the theme animates every element independently. Headers, cards, borders and text all cross-fade on slightly different timings and the page looks like it’s melting. Suppress transitions for one frame:

:root.theme-switching,
:root.theme-switching *,
:root.theme-switching *::before,
:root.theme-switching *::after {
  transition: none !important;
}
function flipWithoutAnimation(choice) {
  root.classList.add('theme-switching');
  setTheme(choice);
  // Force a style flush so the no-transition rule applies to this paint
  void root.offsetHeight;
  requestAnimationFrame(() => root.classList.remove('theme-switching'));
}

If you want the switch to feel deliberate, use document.startViewTransition() with a circular reveal clipped from the toggle button. It degrades to an instant flip where unsupported, and it belongs behind prefers-reduced-motion. The mechanics are the same ones covered in our write-up on the View Transitions API.

What actually breaks (the checklist from real projects)

  • Logos and inline SVG. A dark-ink SVG logo vanishes. Inline it and use fill="currentColor", or ship two files. <picture> with a media="(prefers-color-scheme: dark)" source is tempting, but it ignores your manual toggle. Use CSS-driven swapping instead.
  • Photographs. Don’t filter them. filter: brightness(.8) across all images makes product shots look dirty, which matters on product pages where the image is the sell. Screenshots and diagrams with white backgrounds are the real problem. The fix is a border plus a slightly lighter surface behind them, not a filter.
  • Third-party iframes. Stripe Elements, reCAPTCHA, YouTube, maps. Most expose a theme parameter. Pass it, and re-mount the iframe on toggle, because almost none react to postMessage after load.
  • Form controls and scrollbars. Setting color-scheme fixes these for free. Without it you get white native selects and a white scrollbar gutter on a dark page. This is the single highest-value line in the stylesheet.
  • Forced colors mode. Windows High Contrast is not dark mode. Test with @media (forced-colors: active) in the Chrome DevTools Rendering panel, and make sure anything you built with background images or borders-as-dividers still has structure when all your colours are discarded.
  • Transactional email. Outlook and Apple Mail apply their own dark treatments and will invert your carefully chosen hex values. Treat email as a separate design problem.
  • Logical properties. If the site also runs RTL, theme tokens and direction interact in shadows and gradients that use physical offsets. Our notes on CSS logical properties cover the directional half of this.

Server-rendered sites and the CDN caching trap

If you render HTML per request, you can store the choice in a cookie and emit <html data-theme="dark"> server-side. No inline script, no flash, works with JS disabled. That’s the cleanest result available.

It also fragments your cache. Varying HTML on a cookie means Vary: Cookie, and your CDN now holds a separate entry per theme, or worse, per cookie value if you’re careless. On a marketing site served from the edge, trading a 1ms inline script for a measurable drop in cache hit ratio is usually the wrong choice. Chromium’s Sec-CH-Prefers-Color-Scheme client hint has the same problem plus Chromium-only support.

Our rule: statically generated or edge-cached HTML gets the inline script. Authenticated, per-user, already-uncacheable HTML gets the cookie. Hybrid apps can do both, with the cookie as the source of truth and the script as the fallback for cached shells. If you’re weighing this against hosting choices, the static hosting comparison covers which platforms let you manipulate response headers at the edge cheaply.

Frequently Asked Questions

Should I use a class, a data attribute, or color-scheme for the theme?

Use a data attribute on <html> that sets color-scheme, which is what the code above does. A class works identically for selector purposes, but data-theme reads as state rather than styling and avoids collisions with utility class tooling. The important part is that it lives on <html>, not <body>, so the root background and native UI pick it up.

Is prefers-color-scheme enough on its own?

For a brochure site with no toggle, yes, and it’s three lines of work. Add an explicit toggle when the site has long-form reading, a dashboard, or an international audience where device defaults vary. Once you add the toggle, prefers-color-scheme becomes the default rather than the only input, and you need the three-state model so users can get back to it.

Does the inline head script hurt Core Web Vitals?

Not measurably. It’s a few hundred bytes of parse plus one synchronous localStorage read, typically under a millisecond, and it runs before the stylesheet request completes anyway. What does hurt is the alternative: hiding the document until JavaScript loads delays First Contentful Paint by the full cost of your bundle.

Can I generate a dark palette automatically from my light palette?

Partly. Flipping lightness in OKLCH gets you a usable starting point for neutrals, and it’s a reasonable job to hand to a script. Brand colours, states (success, warning, destructive) and anything with a gradient need manual correction, because perceived saturation changes with background lightness. Expect to hand-tune 10 to 15 tokens out of 40.

How do I test this without clicking through everything manually?

Emulate prefers-color-scheme and forced-colors in the Chrome DevTools Rendering panel for spot checks, then automate contrast with axe-core in Playwright, running the suite twice with data-theme forced to light and dark. Add screenshot diffs for the five or six pages with the most custom styling. That combination catches the two failure classes that matter: insufficient contrast and invisible elements.

Where to start

If you’re retrofitting an existing site, work in this order. Add color-scheme: light dark and the head script first: that removes the flash and fixes native controls in an afternoon. Then rename your colour variables to semantic roles while still in single-theme mode. It’s boring, safe, and the step everyone skips. Only then add the dark values via light-dark(). Doing it in reverse means auditing contrast against a token layer you’re about to rewrite.

If you’re starting fresh and would rather not rebuild the token layer from scratch, both Canvas and CanvasWP ship dark variants with the semantic layer already separated from the primitives, which is the part that takes the longest to get right. Either way, open your site on a phone with dark mode on, hard-refresh with network throttling set to Slow 4G, and watch the first 300ms. That one test tells you whether you’ve actually done this properly.

color-scheme css property form controls dark mode contrast ratio wcag dark mode css dark mode design tokens architecture flash of wrong theme fix implement dark mode without flash light-dark() css function prefers-color-scheme toggle override