Well, yes, actually. The easy case is super easy. That is, the case where you want the theme to be based on the user's system preferences. There's built-in CSS just for that purpose:
@media (prefers-color-scheme: dark) {
/* your css here... */
}
But what if we want to let the user toggle dark mode with a button? Well, that's still easy if you're willing to use JavaScript:
let root = document.getElementById("root")
let button = document.getElementById("button")
let dark = false;
button.onclick = () => {
dark = !dark;
root.style = dark ? "dark mode css" : "light mode css";
}
With a little tweaking, we could even make that support the user's system preference. But what if we did something a little... weirder. A little more... CSS. What if we could add a dark mode selector while also respecting the user's system preference by default, all without a lick of the vile language that is JavaScript? (Ok it's not that bad). And even better, what if we could override the user's selection for certain parts as needed?
Of course, you'll find that I've done just that. Open the menu (mobile) or scroll to the top (desktop) and click the moon icon to mess around with dark mode. Take a look at how this page reacts, including the sections below. Try turning off JavaScript if you can, you'll find the toggle still works!
I didn't even have to specify a bunch of colors to make these components
CSS is turing complete, you know. Not that it helps us here. Also calling it turing complete is
a bit of a stretch. Anyways, what does help us is the
:has()
selector. We can set the CSS of an element based on its children! You could
also make this kind of thing work with
~
if you're so inclined, but it's not as nice for this
usecase.
:has()
has only been majorly supported for a few years, so this
might not work for people who are really lagging on their browser updates.
Even still, we can support their system preference and hide the button for
them, or have a JS fallback. The idea is really simple:
HTML:
<header>
<label class="dark-mode-toggle">
<input type="checkbox" class="dark-mode-toggle" />
Your icon here
</label>
The container of the dark mode button here
</header>
<div>The rest of your site here</div>
CSS:
.light {
--color-theme: white;
--color-text: black;
}
.dark {
--color-theme: black;
--color-text: white;
}
* {
color: var(--color-text);
}
.bg-theme {
background-color: var(--color-theme);
}
:root {
@apply light;
}
:root:has(> label > input.dark-mode-toggle:checked) {
@apply dark;
}
@media (prefers-color-scheme: dark) {
:root {
@apply dark;
}
:root:has(> label > input.dark-mode-toggle:checked) {
@apply light;
}
}
input.dark-mode-toggle,
label.dark-mode-toggle {
display: none;
}
/* Only show the button if it will be supported */
label.dark-mode-toggle:has(input.dark-mode-toggle) {
display: block;
}
We create the dark and light classes, apply them to the root element based
on the system preference, and invert them when the checkbox is clicked.
You can use those CSS variables to style your dark mode button, so it
reacts to the changes in dark modiness. The $gt
makes it match
only immediate children, so you can nest them like they are on this pae.
And that's it! Obviously this is a very basic example; the only two colors
are black and white, and it only handles background and text color (you
might want stroke, fill, etc), but you get the idea. The @apply
lines are tailwind, they just apply the CSS of a class that already exists,
which makes this a little more concise.
Now, you can use the dark
or light
classes
anywhere to control the theme, scoped to whatever element they are applied
to. That's how the light/dark islands at the beginning of this post work.
You could also add more themes, like error
for error states, which
would make that scoping ability extra useful.
This was a fun challenge for me, but I also think it's pretty practical! I always like to support the hypothetical non-JS user, and using only CSS means it will be pretty responsive compared to a JS implementation that has to run on the main thread. My favorite part, though, is that it's just a great way to set up theming, even if you don't have a need for the CSS-inly dark mode selector. And at the end of the day, isn't that what we're all dreaming of? ...right?
Since there is a chance someone's browser won't support this, you can
double-down by adding a JS fallback. That's also pretty easy, just apply
the
light
and dark
classes based on the state of the
checkbox. You can have both the CSS and JS version working at the same time,
they don't conflict with each other. Then you don't have to hide the button
in case it is unsupported. That's what I've done on this site, and it's what
I would recommend if you do implement this approach.