All you need for a proper dark theme
Three states, no flash, reactive. Test it at zwyx.github.io/proper-dark-theme
Although implementing dark theme manually might sound like reinventing the wheel, it's actually easy and there are good reasons to do it:
- dark theme libraries depend on a specific stack,
- UI/component libraries often — incredibly often, based on the dozen I've tested — don't correctly implement the following requirements.
Three requirements for a proper dark theme
Three states
The user must be able to choose same as device
(which should be the default), light
, or dark
.
No flash
If the current theme is dark, the page must not display a white background while it loads (very annoying at night, and possibly problematic for users with vision disabilities).
Reactive
Changing the theme — including the device's theme, when same as device
is selected — should be instantly effective on the page, without requiring to refresh it.
In order to have these features, and not being dependent on a specific stack, I found that the best it to implement dark theme manually.
Once we know how to do it, we can reuse it everywhere. Instead of installing it as a library, we copy and paste it. We « own the code », which is a philosophy I first discovered with Shadcn UI.
Implementation
Our way of doing it will be:
- have JavaScript applying a class
light
ordark
to the root HTML element, and - have different CSS variables depending on this class.
The CSS
CSS variables make it easy to have our app reactive: changing the class on the root HTML element instantly change all the color in the website, no refresh necessary.
:root {
/* variables for light theme */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
}
:root.dark {
/* variables for dark theme */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}
The HTML
To prevent the flash, we want to apply the light
or dark
class as soon as possible. This is what this small block of JavaScript, to be placed in the head
of our HTML, does:
<meta name="theme-color" content="#020817" />
<script>
// Set the theme's class as soon as possible to prevent a flash of the wrong theme
var lightThemeName = "light";
var darkThemeName = "dark";
var storedTheme = localStorage.getItem("theme");
var theme;
if (storedTheme === lightThemeName || storedTheme === darkThemeName) {
theme = storedTheme;
} else if (matchMedia("(prefers-color-scheme: dark)").matches) {
theme = darkThemeName;
} else {
theme = lightThemeName;
}
document.documentElement.classList.add(theme);
if (theme === lightThemeName) {
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", "#ffffff");
}
</script>
The theme-color
meta tag is optional, it's useful mainly if our app is a PWA. When using it, we want to keep it in sync with our theme color.
The JS
The JS has to maintain two state variables:
- the user preference:
light
,dark
, orsame as devices
- the currently displayed theme:
light
, when:- the user preference is
light
- the user preference is
same as device
and the device's theme is light
- the user preference is
dark
, when:- the user preference is
dark
- the user preference is
same as device
and the device's theme is dark
- the user preference is
To do that, the JS has to:
- react to changes of the user's theme preference,
- store it in local storage,
- listen for changes of the system's theme, which is done by adding an event listener to a media query:
matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ()=>{ ... });
- update the HTML root element's class according to the user preference and the system's theme.
I won't include the rest of the code here, instead I invite you to have a look at the implementation made with TypeScript and React for the demo project.
Sidenote
I consider this three-state implementation to be the best at the moment, but I hope that it won't be necessary in the future.
When all operating systems and all web browsers will implement dark theme seamlessly, then offering to the user the possibility to change the theme for a particular website might start to be seen as unnecessary. At least, the setting could be buried in a dialog box instead of being directly available on the top right of the page.
Bonus: with less JavaScript
Here's another way of defining the CSS variables, which greatly reduces the amount of JS required. We use a media query to apply the dark theme when it's the system preference and the user hasn't selected the light theme.
:root {
/* variables for light theme */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
}
:root.dark {
/* variables for dark theme */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}
@media only screen and (prefers-color-scheme: dark) {
:root:not(.light) {
/* variables for dark theme */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
}
}
By doing that, the JS doesn't have to listen for changes of the system's theme anymore.
This is how I used to do it. However, it has a few drawbacks:
- we have to duplicate the declaration of the dark theme's variables,
- the app cannot know which theme is currently displayed when the user preference is
same as device
(Tailwind'sdark:
selector, for instance, doesn't work).