War and genocide have no place in this world, and yet they persist. Sign the Declaration of Peace

Jesse Stuart

Implementing Dark Mode Toggle without JavaScript

2025-10-12

TL;DR; The light-dark CSS function in conjunction with color-scheme make it easy to implement dark mode, and one can implement a toggle in pure CSS/HTML with a combination of the prefers-color-scheme media query with a radio button group and emoji.

This past week I was finally getting around to re-doing my website (and adding this blog)—the old one had been not much more than a clone of my Instagram feed. I was feeling inspired by Lyra’s You no longer need JavaScript post, and decided to challenge myself to avoid using JavaScript and images.

Since I was starting fresh, it didn’t add much effort to use the CSS light-dark function to decide both the light and dark color for everything. And setting color-scheme: light dark in :root to enable both branches of that function was just one extra line. At this point my color scheme would change when toggling dark mode on my operating system, yay!

But wouldn’t it be nice if people visiting my website could decide which color mode they prefer independently of their operating system’s setting? Lyra has a great example of how easy this can be with just CSS and HTML, and I decided to use that as a starting point for what would look like a single button toggle to whichever color mode isn’t in operation. Go ahead and try it out!—toggle , and then toggle it on your system and see what happens (it works with or without JavaScript enabled).

So how does it work? First, we have a radio input group named color-mode, one for each of light, dark, and auto, each with their own unique id and label (notice how we have the one for auto default to checked):

<input style="display:none;" type="radio" id="color-mode-dark" name="color-mode" />
<label for="color-mode-dark" title="Use dark mode"></label>

<input style="display:none;" type="radio" id="color-mode-light" name="color-mode" />
<label for="color-mode-light" title="Use light mode"></label>

<input style="display:none;" type="radio" id="color-mode-auto" name="color-mode" checked="checked" />
<label for="color-mode-auto" title="Use system light/dark mode setting"></label>

Next we have the base functionality in our :root to handle overriding the system setting (notice how we don’t need to explicitly handle the auto case since it’s just un-checking one of the others):

color-scheme: light dark;
&:has(#color-mode-light:checked) {
    color-scheme: light;
}
&:has(#color-mode-dark:checked) {
    color-scheme: dark;
}

Next we need to give auto a label that matches what the system setting is:

@media (prefers-color-scheme: light) {
    label[for="color-mode-auto"]:after {
        content: "";
    }
}

@media (prefers-color-scheme: dark) {
    label[for="color-mode-auto"]:after {
        content: "";
    }
}

And finally, it’d be great if only one label was visible at a time, so we’ll use a combination of :checked and prefers-color-scheme to hide the ones we don’t need to see:

&:has(#color-mode-light:checked) {
    /* hide the currently selected mode */
    label[for="color-mode-light"] {
        display: none;
    }

    /* if system setting matches the current selection, hide auto */
    @media (prefers-color-scheme: light) {
        label[for="color-mode-auto"] {
            display: none;
        }
    }

    /* else, hide the other mode so that only auto is visible */
    @media (prefers-color-scheme: dark) {
        label[for="color-mode-dark"] {
            display: none;
        }
    }
}
/* same as above for dark mode */
&:has(#color-mode-dark:checked) {
    label[for="color-mode-dark"] {
        display: none;
    }

    @media (prefers-color-scheme: dark) {
        label[for="color-mode-auto"] {
            display: none;
        }
    }

    @media (prefers-color-scheme: light) {
        label[for="color-mode-light"] {
            display: none;
        }
    }
}
/* same as above for auto mode */
&:has(#color-mode-auto:checked) {
    label[for="color-mode-auto"] {
        display: none;
    }

    @media (prefers-color-scheme: light) {
        label[for="color-mode-light"] {
            display: none;
        }
    }

    @media (prefers-color-scheme: dark) {
        label[for="color-mode-dark"] {
            display: none;
        }
    }
}

Now our toggle is always for the color mode that’s not currently in use, regardless of if our site toggle or the system one is used!

But unfortunately, CSS isn’t capable of persisting state across page loads, so the toggle will reset if we leave the current page. So we’ll concede and use a bit of JavaScript to enhance the experience when enabled (note that the input elements already need to be in the DOM, so we’ll place the script after them in the HTML):

/* Step 1: save user selection in localStorage */
function saveColorModeSelection(mode) {
  if (mode == "auto") {
    localStorage.removeItem("color-mode");
  } else {
    localStorage.setItem("color-mode", mode);
  }
}

let darkModeInput = document.getElementById("color-mode-dark");
darkModeInput.addEventListener("change", (e) => {
  if (e.target.checked) {
    saveColorModeSelection("dark");
  }
});

let lightModeInput = document.getElementById("color-mode-light");
lightModeInput.addEventListener("change", (e) => {
  if (e.target.checked) {
    saveColorModeSelection("light");
  }
});

let autoModeInput = document.getElementById("color-mode-auto");
autoModeInput.addEventListener("change", (e) => {
  if (e.target.checked) {
    saveColorModeSelection("auto");
  }
});
/* Step 2: apply user selection on page load */
let colorMode = localStorage.getItem("color-mode");
switch (colorMode) {
  case "light":
    lightModeInput.checked = true;
    break;

  case "dark":
    darkModeInput.checked = true;
    break;

  default:
    autoModeInput.checked = true;
    break;
}

I originally also included this last snippet to erase the user selection when the system mode changes to match:

/* Optional step 3: Erase user selection when system mode changes to match */
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (e) => {
  if (e.matches && lightModeInput.checked) {
    autoModeInput.checked = true;
    saveColorModeSelection("auto");
  }
});

window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
  if (e.matches && darkModeInput.checked) {
    autoModeInput.checked = true;
    saveColorModeSelection("auto");
  }
});

But I’m not 100% sure about the UX of this; While I think this is sometimes what the user wants, it could also get really annoying for someone who always wants to experience my website in one mode or another, and there’s always the option to use the system setting if you want it to auto-transition. I do think the UX of erasing the preference when manually changing the mode to match the system is correct though, since I think it’d be very annoying if overriding that setting once made you have to always manually toggle it (or manually clear the localStorage variable). Of course, we could have a button somewhere to manage such preferences, but I think that’s probably overkill for a blog ;) .

Color Mode