---
title: "How to Implement Dark Mode Properly in CSS"
description: "Build dark mode using CSS custom properties. Respect system preferences, add a persistent toggle, avoid pure black, and prevent the flash of wrong theme."
url: https://frontendhelpers.com/dark-mode-implementation-guide
updated: 2026-10-12
---

# How to implement dark mode properly

Design tokens, system preference, a toggle that persists, and the flash-of-wrong-theme problem nobody warns you about.

Guide · By [AurumCodeWorks](https://frontendhelpers.com/about) · 4 min read · Updated 19 July 2026

## Start with tokens, not colours

Dark mode is painful to retrofit and trivial to build in — and the difference is entirely whether your colours are named. If your stylesheet is full of raw hex values, supporting a second theme means finding and overriding every one. If it references tokens, a theme is just a different set of values for the same names.

Define your palette once as custom properties on `:root`, then reference only those tokens everywhere else. Name them by _role_, not appearance: `--surface` and `--text-primary` survive a theme switch, while `--light-grey` becomes a lie the moment dark mode ships.

## Respect the system preference first

Most operating systems already have a light/dark setting, and visitors expect sites to honour it. The `prefers-color-scheme` media query reads it, and supporting it should be your default behaviour — a toggle is an override, not the primary mechanism.

Also set the `color-scheme` property. It tells the browser to render native controls — scrollbars, form inputs, date pickers — in the matching theme. Without it you get dark pages with jarring white scrollbars and light dropdowns, a detail covered in our [themed scrollbars lesson](https://frontendhelpers.com/css-animations#learn-scrollbars).

## Adding a toggle that persists

The standard approach is a `data-theme` attribute on the root element that overrides the media query, with the choice saved in `localStorage`. Three pieces: read the stored value on load, apply it as an attribute, and write the new value when the user toggles.

Offer three states if you can — light, dark, and "system" — with system as the default. A toggle that permanently overrides the OS preference is worse than no toggle for people who switch automatically at sunset.

## The flash of wrong theme

Here's the problem nobody mentions until it happens: if you apply the saved theme in a script at the end of the body, the page renders in the default theme first and then flips. On a dark-mode preference that's a bright white flash — genuinely unpleasant in a dark room.

The fix is a small **blocking** inline script in the `<head>`, before any CSS is applied, that reads localStorage and sets the attribute on `documentElement` immediately. It's one of the rare legitimate uses of a render-blocking script — it must run before first paint, and it's only a few lines.

## Dark mode is not inverted light mode

Several design rules change in the dark. **Avoid pure black backgrounds** — pure white text on pure black causes halation, where letters appear to smear for many readers, and it's fatiguing. Use a dark grey or navy, around #121212 to #1a1a2e.

**Avoid pure white text** for the same reason; a light grey at roughly 87% opacity is more comfortable. **Desaturate your accent colours**, because saturated colours vibrate uncomfortably against dark backgrounds. And remember that **shadows barely register** in dark mode — convey elevation with lighter surface colours or subtle top-edge highlights instead, which is exactly what this site does.

## Check contrast again

Your light-mode contrast results tell you nothing about dark mode. A grey that passes AA on white will usually fail on a dark background, and it's a common oversight because the design "looks fine" to whoever built it.

Run every text-on-surface pairing through the [contrast checker](https://frontendhelpers.com/contrast-checker) in both themes. Also test images and illustrations — logos with white backgrounds and screenshots of light interfaces look broken in dark mode, and often need a variant or a subtle background treatment.

Our [palette generator](https://frontendhelpers.com/color-palette-generator) produces the tint and shade scale that makes maintaining two themes straightforward: the same hue at different lightness levels, exported as tokens.

## Frequently asked questions

### How do I detect if a user prefers dark mode?

Use the prefers-color-scheme media query in CSS, or window.matchMedia("(prefers-color-scheme: dark)") in JavaScript. Honouring the system preference should be your default, with any toggle acting as an override.

### Why does my site flash white before dark mode loads?

Because the theme is applied after first paint. Fix it with a small blocking inline script in the head that reads localStorage and sets the theme attribute on documentElement before any CSS renders.

### Should dark mode use pure black backgrounds?

No. Pure white text on pure black causes halation, where letters appear to smear, and is fatiguing to read. Use a dark grey or navy around #121212 to #1a1a2e with slightly off-white text.

## Keep going

- [Colour Palette Generator](https://frontendhelpers.com/color-palette-generator): Build the tint and shade scale that powers two themes.
 
- [WCAG Contrast Checker](https://frontendhelpers.com/contrast-checker): Verify contrast in both light and dark themes.
 
- [Theme tokens, interactively](https://frontendhelpers.com/css-animations): A live demo of token-driven theme switching.
