Dark mode has become a standard feature across apps and websites, and there is no shortage of ways to implement it. Even so, the common approaches do not always line up with the way I think theme switching should behave.
Recently, after reworking a light palette analysis and regenerating the dark colors for the Weisay Grace theme, the dark scheme moved away from plain black layers with different opacity levels. It now includes deep blue tones, which makes the light and dark variants feel more unified and visually balanced.
Three common ways to handle dark mode
1. Basic toggle
This is the simplest version: a switch between light and dark.
Once a user picks dark mode, the site keeps that choice and stops responding to changes in the system theme. The same applies if the user selects light mode.
2. Three-option switch
This adds an Auto option on top of the basic light/dark toggle.
With Auto enabled, the site follows the operating system setting. But if the user manually picks a fixed mode, the theme stops changing when the system setting changes.
3. Time-based switching
In this model, the theme changes automatically according to time of day. For example, the site switches to dark mode at night and returns to light mode during the day. Manual switching is still possible.
Where these approaches fall short
The basic toggle is straightforward, but it completely ignores the system environment.
The three-option version is more flexible because it introduces automatic behavior, but it also makes switching less direct. In practice, the extra option can mean more clicks than necessary.
The time-based approach is a little too forceful. It decides on the user’s behalf based on the clock, which does not always match personal preference.
The interaction model I prefer
I lean toward a single-button toggle with slightly different behavior:
- On the first visit, the site should follow the system preference by default.
- If the user manually toggles the theme, that choice should temporarily override the system setting.
- When the system theme changes again later, the site can resync with it so the overall experience stays consistent.
The idea is not to treat a manual choice as a permanent lock forever. Instead, it works as a temporary override until the surrounding environment changes again.
How it behaves in practice
Initial state
- System: light
- Site: follows the system by default → light
User manually switches the site
- Site is manually changed to → dark
At this point, the site ignores the current system theme and remembers the user’s choice.
System theme changes to dark
- System: dark
- Site: stays dark because the user had already chosen dark
In other words, there is no visible conflict, so the experience remains stable.
System theme changes back to light
- System: light
- Site: returns to light and resumes following the system
This is the key part of the design. A manual switch temporarily overrides the system, but a later system-level theme change can bring the site back into sync.
Why this works well
This model balances two things that are often treated separately:
- respect for the user’s system preference
- room for a quick personal override when needed
It avoids the rigidity of a permanent manual setting while still giving users control. For many cases, that produces a smoother and more consistent experience.
That said, it is not ideal for everyone. If someone wants the site to remain fixed in one mode no matter what happens at the system level, this behavior will feel too dynamic.
Implementation approach
The mechanism is based on adding either a dark or light class to the html element and using a toggle button to switch between them.
JavaScript
const rootElement = document.documentElement;
const darkModeClassName = "dark";
const darkModeStorageKey = "user-color-scheme";
const validColorModeKeys = { dark: true, light: true };
const invertDarkModeObj = { dark: "light", light: "dark" };
const setLocalStorage = (key, value) => {
try {
localStorage.setItem(key, value);
} catch (e) {}
};
const removeLocalStorage = (key) => {
try {
localStorage.removeItem(key);
} catch (e) {}
};
const getLocalStorage = (key) => {
try {
return localStorage.getItem(key);
} catch (e) {
return null;
}
};
const getModeFromCSSMediaQuery = () => {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
const setColorScheme = (mode) => {
rootElement.classList.remove(mode, invertDarkModeObj[mode]);
rootElement.classList.add(mode);
};
const resetRootDarkModeClassAndLocalStorage = () => {
rootElement.classList.remove(darkModeClassName, invertDarkModeObj[darkModeClassName]);
removeLocalStorage(darkModeStorageKey);
};
const applyCustomDarkModeSettings = (mode) => {
// 接受从「开关」处传来的模式,或者从 localStorage 读取
const currentSetting = mode || getLocalStorage(darkModeStorageKey);
if (currentSetting === getModeFromCSSMediaQuery()) {
// 当用户自定义的显示模式和 prefers-color-scheme 相同时重置、恢复到自动模式
resetRootDarkModeClassAndLocalStorage();
setColorScheme(currentSetting);
} else if (validColorModeKeys[currentSetting]) {
rootElement.classList.add(currentSetting);
rootElement.classList.remove(invertDarkModeObj[currentSetting]);
} else {
// 首次访问或从未使用过开关、localStorage 中没有存储的值,currentSetting 是 null
// 或者 localStorage 被篡改,currentSetting 不是合法值
resetRootDarkModeClassAndLocalStorage();
// 使用系统当前方案
setColorScheme(getModeFromCSSMediaQuery());
}
};
const toggleCustomDarkMode = () => {
let currentSetting = getLocalStorage(darkModeStorageKey);
if (validColorModeKeys[currentSetting]) {
// 从 localStorage 中读取模式,并取相反的模式
currentSetting = invertDarkModeObj[currentSetting];
} else if (currentSetting === null) {
// localStorage 中没有相关值,或者 localStorage 抛了 Error
// 从 CSS 中读取当前 prefers-color-scheme 并取相反的模式
currentSetting = invertDarkModeObj[getModeFromCSSMediaQuery()];
} else {
// 不知道出了什么幺蛾子,比如 localStorage 被篡改成非法值
return; // 直接 return;
}
// 将相反的模式写入 localStorage
setLocalStorage(darkModeStorageKey, currentSetting);
return currentSetting;
};
// 当页面加载时,将显示模式设置为 localStorage 中自定义的值(如果有的话)
applyCustomDarkModeSettings();
const onSystemSchemeChanged = (event) => {
// 获取新的系统主题方案
const newColorScheme = event.matches ? "dark" : "light";
// 用户主动配置了系统方案,清除用户之前记忆
resetRootDarkModeClassAndLocalStorage();
// 使用系统当前方案
setColorScheme(newColorScheme);
};
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
// recommended method for newer browsers: specify event-type as first argument
darkModePreference.addEventListener("change", onSystemSchemeChanged);
// deprecated method for backward compatibility
darkModePreference.addListener(onSystemSchemeChanged);
CSS
:root {
--text: #111;
--background: #eee;
}
.dark {
--text: #ccc;
--background: #111;
}
body {
color: var(--text);
background: var(--background);
}
Using :root is a solid way to manage theme variables globally. Define CSS custom properties once, change their values, and the whole interface updates consistently. That makes it especially suitable for theme switching and responsive design.
Personally, though, I do not naturally work that way. Since the JavaScript is already adding either light or dark to the html element dynamically, it is also practical to define dark-mode styles directly under .dark.
Matching the scrollbar to dark mode
If you want the browser scrollbar to react to the current color scheme as well, add this to the page head:
<meta name="color-scheme" content="light dark" />
And include the following CSS:
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}