It’s surprisingly unwieldy to add a force dark/light mode button to a website. I thought there might be a way to influence prefers-color-scheme but there doesn’t seem to be.
I saw this post on Bluesky and thought I’d write up how I do light/dark mode on my website with CSS variables.
TLDR
- html
<!-- Set the default theme to one of "light", "dark", "auto" -->
<html lang="en" data-theme="auto">
<head>
<!-- Set the HTML theme preference -->
<meta name="color-scheme" content="light dark">
</head>
</html>
- css
:root {
/* Color tokens */
--white: rgb(251 249 255);
--black: rgb(12 12 14);
/* Match this to HTML theme preference */
color-scheme: light dark;
--theme-light: initial;
--theme-dark: ;
}
@media (prefers-color-scheme: dark) {
:root {
--theme-dark: initial;
--theme-light: ;
}
}
:root[data-theme="light"] {
color-scheme: light;
--theme-light: initial;
--theme-dark: ;
}
:root[data-theme="dark"] {
color-scheme: dark;
--theme-dark: initial;
--theme-light: ;
}
body {
/* Color tokens used here! */
background-color: var(--theme-light, var(--white)) var(--theme-dark, var(--black));
color: var(--theme-light, var(--black)) var(--theme-dark, var(--white));
}
- js
// Load selected theme from localStorage.
// Inline in <head> to reduce FOUT.
(function () {
const root = document.documentElement;
if (typeof localStorage !== "undefined") {
const theme = localStorage.getItem("theme");
if (theme) {
root.setAttribute("data-theme", theme);
}
else {
// Set the default theme to one of "light", "dark", "auto"
localStorage.setItem("theme", "auto");
root.setAttribute("data-theme", "auto");
}
}
})();
How It Works
The first component to this madness is of course, CSS custom properties!
- Example
<style>
#ex1 {
--color: red;
}
#ex1 :nth-child(1) {
background-color: var(--color);
}
</style>
The second component is the fact that the initial value for CSS variables is a guaranteed-invalid value:
… using
var()
to substitute a custom property with this as its value makes the property referencing it invalid at computed-value time.
Therefore, setting a CSS variable to initial
will result in an invalid value, causing the fallback argument to be substituted instead:
The second argument to the function, if provided, is a fallback value, which is used as the substitution value when the value of the referenced custom property is the guaranteed-invalid value.
- Example
<style>
#ex2 {
--color: initial;
--red: red;
--blue: blue;
}
#ex2 :nth-child(1) {
background-color: var(--color, var(--red));
}
#ex2 :nth-child(2) {
background-color: var(--color, var(--blue));
}
</style>
The third component is the fact that you can set custom properties to the empty value, and that empty value is valid. The documentation for guaranteed-invalid values also makes sure to highlight this weird quirk:
This value serializes as the empty string, but actually writing an empty value into a custom property, like
--foo: ;
, is a valid (empty) value, not the guaranteed-invalid value. If, for whatever reason, one wants to manually reset a variable to the guaranteed-invalid value, using the keywordinitial
will do this.
Thus, we can set a CSS variable to empty to effectively hide the variable from substitution! Combining this component with the previous example:
- Example
<style>
#ex3 {
--red: red;
--blue: blue;
}
#ex3 :nth-child(1) {
/* Here, 'a' is set and 'b' is unset */
--toggle-a: initial;
--toggle-b: ;
background-color: var(--toggle-a, var(--red)) var(--toggle-b, var(--blue));
}
#ex3 :nth-child(2) {
/* Here, 'b' is set and 'a' is unset */
--toggle-a: ;
--toggle-b: initial;
background-color: var(--toggle-a, var(--red)) var(--toggle-b, var(--blue));
}
</style>
Putting It All Together
I hope you can now see how this is really useful for light/dark modes, because _changing the theme is merely setting two CSS variables --theme-light
and --theme-dark
to initial
and empty respectively!
By setting an HTML data attribute, we can support the following:
-
Automatic theme according to
prefers-color-scheme
- Explicit light mode
- Explicit dark mode
:root {
/* Match this to HTML theme preference */
/* Implicit light mode */
color-scheme: light dark;
--theme-light: initial;
--theme-dark: ;
}
/* Implicit dark mode */
@media (prefers-color-scheme: dark) {
:root {
--theme-dark: initial;
--theme-light: ;
}
}
/* Explicit light mode */
:root[data-theme="light"] {
color-scheme: light;
--theme-light: initial;
--theme-dark: ;
}
/* Explicit dark mode */
:root[data-theme="dark"] {
color-scheme: dark;
--theme-dark: initial;
--theme-light: ;
}
The best part about this trick is that it works for any CSS value, so
padding: var(--theme-light, 2rem) var(--theme-dark, 8rem);
margin: var(--theme-dark, 4rem);
works exactly how you would expect it to!
Codepen Example
Check out this minimal theme switcher and proof-of-concept on Codepen:
See the Pen No Duplication CSS Theming by kosayoda (@kosayoda) on CodePen.
Miscellaneous Thoughts
The problem of duplicating CSS variables for media queries and CSS selectors, since there is no way to combine the two:
:root {
--light-color: red;
--dark-color: blue;
/* Default light theme */
--bg-color: var(--light-color);
}
/* Implicit dark theme */
@media (prefers-color-scheme: dark) {
/* When not explicit light theme */
:root:not([data-theme="light"]) {
--bg-color: var(--dark-color);
}
}
/* Explicit dark theme */
:root[data-theme="dark"] {
--bg-color: var(--dark-color);
}
/* Whatever setting gets picked, we'll use it here */
html {
background-color: var(--bg-color);
}
Note that above, the same configuration is duplicated across the implicit and explicit dark themes.
In most cases, you would only need to define semantic colors (eg. --text-color
) once, which you can then use everywhere on the site.
My main gripe is that sometimes I want to write custom styling for a post, which then requires the whole song and dance with media queries and attribute selectors at every definition site of the custom styles.
It’s pretty cool! Unfortunately, it’s not a general solution as it only works with colors.