There are two themes. Light and dark.
This should require one boolean:
const dark = true;Finished.
Then someone asks: “Can it follow the system?”
Now you have:
system
light
darkThe boolean had a short but meaningful career.
The simplest dark mode needs no JavaScript
@media (prefers-color-scheme: dark) {
body {
background: #111;
color: #eee;
}
}The browser reads the operating system preference. Dark system, dark website. Light system, light website.
Then you add a manual theme button.
User choice should override system choice
A device may be dark while the user wants this particular site to stay light. Refreshing should preserve that choice, so the site needs persistence:
localStorage.setItem("theme", "light");The logic becomes: use an explicit user choice when one exists; otherwise follow the system. That is why the clean state model is usually system / light / dark, not a forced true/false.
Then the page flashes white
Your theme works, but a refresh shows bright white for a moment before switching to dark.
The loading sequence explains it:
HTML starts rendering
↓
default light CSS paints
↓
JavaScript loads
↓
localStorage says "dark"
↓
dark theme appliesThe browser did exactly what you asked. Unfortunately, the user's retina observed the timeline.
A common fix is a tiny early script that reads the saved preference and prefers-color-scheme before the first meaningful paint, then sets a theme attribute on the root element.
CSS variables scale better than duplicated styles
:root {
--bg: white;
--text: #111;
--surface: #f4f4f4;
}
html[data-theme="dark"] {
--bg: #111;
--text: #eee;
--surface: #1c1c1c;
}Components use semantic variables such as var(--surface) without caring how the theme is implemented.
The browser has colors too
Mobile address bars, status areas and installed PWA chrome can use theme-color. If your page turns dark while the browser bar stays bright white, the interface looks as if it has a flashlight attached to its forehead.
Dynamic themes should update the theme-color metadata too.
Native controls need a hint
The CSS color-scheme property tells the browser which themes the page supports, helping built-in controls and scrollbars match. Otherwise one aggressively white native control may remain inside an otherwise polished dark interface.
System means continuing to follow the system
If the user selected System and the operating system changes theme at night, the page should react. A listener on matchMedia("(prefers-color-scheme: dark)") can update the page only while the saved mode is System.
If the user explicitly chose Light, the website should not override that just because the clock moved.
Dark mode is not simply “background: black”
Check contrast for primary and secondary text, disabled controls, borders, links, focus indicators, error states and icons. Elegant low-contrast gray can become an accessibility scavenger hunt on a normal screen outdoors.
The final lesson
Dark mode starts as a color change, then becomes state management, persistence, initial rendering, system event handling, browser UI and accessibility.
That is quite a career for a button containing a moon icon.
The clean model is still simple:
system
light
darkRespect explicit user choice, make System genuinely follow the system, and apply the initial theme early.
Then dark mode becomes a real feature instead of paint applied after the page loads.