No JS Dark Mode

Isn't that easy?

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!

Here's a little patch of light mode

Example Card

With some ready-to-go components

Ok, not that many

I only have so many theme-dependent components at the moment
And here's a little patch of dark mode

Another Card

With some ready-to-go components
And what's this? A dark mode button within the dark modes? Should you even dare to click it? Is having nested dark mode selectors even useful?? (probably not)

And

I didn't even have to specify a bunch of colors to make these components

How?

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.

Why?

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?

P.S. - JS Fallback

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.