Despite feature such as prefers-color-scheme, building a dark mode toggle switch can be a pain. There's a number of ways that the user can change their preferred theme and we need to handle all those cases.
Easiest method but only works if the user agent prefers a color scheme. In Firefox this can be set
through the ui.systemUsesDarkTheme
option in about:config
or set by a theme. Both Firefox and
Chrome should also inherit the preference from the OS. Both browser's devtools also allow temporary
configuration of the preferred color scheme for testing.
@media (prefers-color-scheme: light) {
:root {
background-color: #fdf6e3;
color: black;
}
}
@media (prefers-color-scheme: dark) {
:root {
background-color: #1c1c1c;
color: #cccccc;
}
}
Simple and straightforward, the media query handles the rule change and we just need to design the colors to our preference.
Now we need a way for our CSS rules to toggle between light and dark modes. The easiest way is to add a data attribute that we can query in CSS to override the media query. We have to duplicate all the existing properties but it's the only way to ensure that the correct theme is displayed.
/* dark theme override browser preference */
:root[data-color-scheme="dark"] {
background-color: #1c1c1c;
color: #cccccc;
}
:root[data-color-scheme="dark"] a:link {
color: #a3b6ff;
}
The selector specificity here allows these rules to override those similar selectors in the
media query and the descendent combinator is used to target other elements that we wish to style.
With these rules in place all we need to do is add a button that adds our data-color-scheme
attribute to the :root
element.
My switch consists of a sun and moon icon that interchange by setting display: none
.
<div class="color-scheme-toggle display-none" aria-hidden="true">
<span class="sun-icon">☀️</span>
<span class="moon-icon">🌙</span>
</div>
The parent div initially has the display-none
utility class which prevents it from showing
until JS is ready. A keen observer will note that I set aria-hidden="true"
. I have no idea if
that was the right decision or not.
Do people that use screen readers also have a preference between light and dark modes?
There are a few edge cases to handle but the code is just a few event listeners.
export function darkMode() {
const colorSchemeToggle = document.querySelector(".color-scheme-toggle");
colorSchemeToggle.classList.remove("display-none");
const moonIcon = colorSchemeToggle.querySelector(".moon-icon");
const sunIcon = colorSchemeToggle.querySelector(".sun-icon");
moonIcon.addEventListener("click", () => {
moonIcon.classList.add("display-none");
sunIcon.classList.remove("display-none");
document.documentElement.setAttribute("data-color-scheme", "light");
});
sunIcon.addEventListener("click", () => {
sunIcon.classList.add("display-none");
moonIcon.classList.remove("display-none");
document.documentElement.setAttribute("data-color-scheme", "dark");
});
const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
const lightMode = window.matchMedia('(prefers-color-scheme: light)');
if(darkMode.matches) {
sunIcon.classList.add("display-none");
document.documentElement.setAttribute("data-color-scheme", "dark");
}
else if(lightMode.matches) {
moonIcon.classList.add("display-none");
document.documentElement.setAttribute("data-color-scheme", "light");
}
darkMode.addEventListener("change", e => {
if(e.matches) {
document.documentElement.setAttribute("data-color-scheme", "dark");
sunIcon.classList.add("display-none");
moonIcon.classList.remove("display-none");
}
});
lightMode.addEventListener("change", e => {
if(e.matches) {
document.documentElement.setAttribute("data-color-scheme", "light");
moonIcon.classList.add("display-none");
sunIcon.classList.remove("display-none");
}
});
}
We start by un-hiding the toggle switch because we know that our JS has loaded. Next all we have to do is listen for clicks, the default preferred color scheme, and the user changing their default.
Done! But there are a lot of possible improvements:
This was a complete waste of time and effort for a feature like dark mode. I guess there's value in switchable themes but for a well-designed application is it really important that the end-user can switch themes at will? The amount of code we have to write just explodes with duplication and state handling compared to the CSS-only solution, and it's becoming easier for users to opt-into dark mode via the OS or browser setting.