Blog

Dark mode has three states, not two

Most implementations build light and dark, and lose the ability to follow the operating system the moment somebody touches the toggle once.

Published

A Mac set to switch appearance by time of day goes dark at sunset on its own. Nobody does anything. The whole machine shifts together.

On a lot of websites, the tab left open since the afternoon stays white. Three weeks ago that person pressed the theme toggle once, and from that second the site stopped listening to the operating system forever.

That is among the most common dark mode bugs, and it comes from miscounting the states at the very beginning.

Three states, not two

A site's theme is not light or dark. It is one of three things.

Follow the operating system. The default for anyone who has never chosen, and it must keep following, live, when the machine changes appearance while the page is open.

Forced light. Someone who wants light even on a dark machine.

Forced dark. Someone who wants dark even on a light machine.

People set the machine's preference in macOS System Settings under Appearance, iOS under Settings, Display & Brightness, Windows 11 under Settings, Personalisation, Colours, and Android under Settings, Display, Dark theme. Several of those can also schedule the change by time of day, which is precisely why a site has to keep listening rather than sample the value once at load and remember it.

So the dark rules on this site are written twice. Once under @media (prefers-color-scheme: dark) for visitors who never chose, guarded with :root:not([data-theme="light"]) so an explicit light choice still wins. Once under :root[data-theme="dark"] for visitors who did choose. No stored value means follow the OS. It does not mean light.

The flash of the wrong theme

Someone who has chosen dark should never see a white frame before the page settles. If they do, no amount of better code fixes it — the code is simply in the wrong place.

Reading the stored choice and putting data-theme on <html> has to happen before the first paint. The only mechanism that guarantees that is a blocking inline script in <head>. The browser stops building the document, runs it, then paints.

What cannot work is a React effect. Effects run after paint by definition. However fast it is, it is a frame late — and one full-screen white frame in a dark room is noticed every single time.

The script is deliberately tiny: read localStorage, and if the value is dark or light, set the attribute. If there is nothing stored, set nothing and let the media query do its job. The whole thing sits inside a try/catch, because reading storage throws outright in some private browsing modes, and a broken theme should never break the page.

Why the toggle holds no React state

The theme toggle in the footer contains no useState. That looks wrong until you say the reason out loud.

The theme is only knowable in the browser. The server renders one set of HTML for everyone; it does not know how the reader's machine is set, and it does not know what they chose last time. Rendering the current theme into JSX is therefore exactly the thing that produces a hydration mismatch — the server's markup and the browser's idea of correct markup disagree on the first render.

So the component renders both. Both icons, sun and moon, and both labels. CSS decides which one is visible. The HTML is identical for every visitor, and the correct icon is showing in the very first frame without waiting for JavaScript to arrive.

The click handler writes the attribute on <html> and stores the choice. There is no second copy of the truth to keep in sync.

The label names the action, not the state

Two-state toggles carry a classic ambiguity. A button reading "Dark mode" — does that mean you are in dark mode, or that pressing it gets you there?

You have to pick one and be consistent. This site labels the action: in the light theme the button says "Dark mode", because that is what the click will do.

The reasoning is that the current state is already visible — the entire page is the indicator, and no label competes with that. What is invisible is the consequence of pressing. That is the thing worth spending the label on.

color-scheme is the line people forget

color-scheme is a CSS property that tells the browser which theme the page is in. Its effect is not on anything you paint yourself; it is on the parts the browser paints: scrollbars, form controls, and the overscroll gutter.

Leave it out and you get a dark page sitting inside a white frame — pale scrollbar, pale form fields, and a flash of white when the page is dragged past its edge.

A related detail: the page ground is set on html, not only on body. The canvas behind the document, including the space between sections and the overscroll area, takes its colour from html first.

The token lesson: one brand colour cannot do two jobs

This took the longest of anything in the work.

Brand blue was doing two jobs at once. It fills buttons, and it is read as link text. On a white background those two coexist quietly.

Add a dark theme and the same blue on near-black is unreadable. The obvious fix is to lighten it — and lightening it lightened every filled button too, at which point white type on those buttons lost its contrast. Fixing one thing broke the other.

The resolution was to split the colour into two tokens. The fill stays the brand's own #0049ab in both themes and does not move. Type and links get a token of their own, and it is the only one that changes, stepping up to a brighter blue on a dark ground.

The general lesson is worth keeping: when one colour serves two roles with different contrast requirements, it was always two tokens that happened to hold the same value. The dark theme is just what makes that visible.

A dark theme is not inverted colours

One short point to finish on. A dark theme produced by inverting everything gives you pure white text on pure black, which is uncomfortable to read for any length of time. At that contrast the edges of letterforms shimmer slightly.

So this site uses a near-black ground with a little colour in it rather than true black, and a softened off-white for type rather than pure white. The difference barely registers in a screenshot. It registers clearly after ten minutes of reading.

What GIPSIC does here

We build websites and systems for organisations, and detail at this level is part of the work rather than a finishing touch on top of it.

If you are adding a dark theme to something that already exists, the thing we will want to talk about in the first meeting is your colour set. The work that actually takes time is rarely the CSS. It is discovering how many jobs a single colour has quietly been doing.

If you would like to talk about your system, get in touch.