Skip to content
Ramesh Gautam

Dark mode with CSS custom properties and no flash on load

Dark mode with CSS custom properties and no flash on load, a Frontend article by Ramesh Gautam

The light and dark switch on this site is a few dozen lines of CSS and a small script. Most of the work happened earlier, when every colour became a CSS custom property. If your colours are tokens, dark mode is a matter of swapping values.

Start with semantic tokens

Components should never reference a raw colour. They reference a role:

.card {
	background: var(--color-surface);
	color: var(--color-text);
	border: 1px solid var(--color-border);
}

Then each mode sets those roles:

:root {
	--color-background: #f5f7f6;
	--color-surface: #ffffff;
	--color-text: #2b3532;
	--color-border: #dde3e0;
	color-scheme: light;
}

[data-mode="dark"] {
	--color-background: #141918;
	--color-surface: #1c2321;
	--color-text: #d8e0dd;
	--color-border: #2e3835;
	color-scheme: dark;
}

color-scheme is easy to forget. It tells the browser to draw scrollbars, form controls and the default canvas in the right mode.

Dark mode isn’t inverted light mode. Pure black backgrounds with pure white text cause halation for many readers, so use a very dark tinted grey and slightly softened text. Brand accents usually need a lighter shade in dark mode to keep enough contrast.

Three states, not two

Visitors fall into three groups: those who chose light, those who chose dark, and those who never chose. The third group should follow their operating system:

const saved = localStorage.getItem( 'mode' ); // 'light', 'dark' or null
const mode = saved || ( matchMedia( '(prefers-color-scheme: dark)' ).matches ? 'dark' : 'light' );
document.documentElement.dataset.mode = mode;

Only store a value when someone actually clicks the toggle. Saving the system preference on first visit would freeze it, and the site would stop following the OS when it changes at sunset.

Stop the flash

If that script runs after the CSS and page content, the page paints in light mode first and then switches. The fix is to run it as a tiny inline script at the very top of <head>, before any stylesheet:

<head>
	<script>/* the four lines above, minified */</script>
	<link rel="stylesheet" href="main.css">
</head>

It’s blocking on purpose. It takes well under a millisecond and runs before the first paint, so there’s nothing to flash. In WordPress, hook it to wp_head at priority 0.

An accessible toggle

The toggle is a real <button> with aria-pressed reflecting the state, and a label that says what will happen:

<button type="button" aria-pressed="false" title="Switch to dark mode">
	<span class="screen-reader-text">Dark mode</span>
</button>

If JavaScript fails, the button shouldn’t appear at all, because it would do nothing. Render it with the hidden attribute and remove that in the script.

Smooth, but only when asked

Animating every colour all the time makes hovers sluggish. Instead, add a class for a moment during a switch and transition only then, and skip it entirely for people who prefer reduced motion:

.mode-switching * {
	transition: background-color 180ms ease, color 180ms ease;
}

@media (prefers-reduced-motion: reduce) {
	.mode-switching * { transition: none; }
}

Test contrast in both modes

Every text and background pair needs to pass WCAG AA in both modes, 4.5:1 for body text. Check links and muted text in particular, since those are the colours that usually fail. My accessibility checklist covers the rest.