How to Build a Theme Switcher for Light/Dark Mode With Vanilla CSS and Javascript

The other day I added a theme switcher to this, here website (up there, in the header ⤴). Turns out it's fun and pretty simple to do. Let's build one together.

Here's a Peek at What We're Building

See the Pen Theme Switcher - Light/Dark Mode by Andrew Houle (@andrewhoule) on CodePen.

Other than looking pretty cool, and being fun to create, a light/dark mode toggle is an accessibility win, especially for visually impaired users.

Before we dive into the code, I want to add the caveat that there are many, many, many ways to do this. This is my humble approach.

Starting With HTML

When I was first getting into frontend development—about 100 years ago—I read a book called Transcending CSS, by Andy Clarke. It was a great read! One of my biggest takeaways was to always start with semantic markup. It helps with accessibility, and it helps you strip everything down, so you can think clearly. Think of it like a clutter-free desk.

With that ideology in mind, here's the basic structure...

 <button
  aria-label="Switch to light theme"
  aria-pressed="false"
  class="theme-switcher"
  type="button"
>
  <span class="theme-switcher__icon theme-switcher__icon--sun"></span>
  <span class="theme-switcher__icon theme-switcher__icon--moon"></span>
</button>

Nothing, too extravegant here, just a button with some aria attributes and a couple of spans for the icons. We are defaulting to dark mode, but we'll use javascript to update to the user preference.

Time to Style Things With CSS

First, let's define our colors and theme switcher vars. We are going to default to dark mode, but again, we'll let the user defaults decide things (via JS).

:root {
  --color--fog: #ccc;
  --color--graphite: #333;
  --color--mist: #eee;
  --color--slate: #111;
  --color--yellow: #e1ac27;
  --body__background-color: var(--color--slate);
  --body__color: var(--color--mist);
  --theme-switcher__height: 42px;
  --theme-switcher__gap: 6px;
  --theme-switcher__background-color: var(--color--graphite);
  --theme-switcher-switch__left: calc(var(--theme-switcher__gap) / 2);
  --theme-switcher-switch__transition: inherit;
}

I know, I know, my custom property names are weird. Let's talk about that. I've been calling this approach BPM. Think of it like BEM, only instead of block__element__modifier, it's block__property--modifier. What it lacks in brevity, it makes up for with clarity and memorability. I love a good naming convention!

For the vars themselves... Just some colors, and the theme switcher settings.

Now we need to add some default styles to the body, and some updates when the theme is switched. e.g. change the colors, and transition the switch.

body {
  background-color: var(--body__background-color);
  color: var(--body__color);
}

html[data-theme="light"] {
  --body__background-color: var(--color--mist);
  --body__color: var(--color--slate);
  --theme-switcher__background-color: var(--color--fog);
  --theme-switcher-switch__left: calc(100% - var(--theme-switcher__height) + var(--theme-switcher__gap) / 2);
}

Next up, let's style the switcher background and switch itself...

.theme-switcher {
  background-color: var(--theme-switcher__background-color);
  border-radius: calc(var(--theme-switcher__height) / 2);
  border: 1px solid transparent;
  cursor: pointer;
  height: var(--theme-switcher__height);
  position: relative;
  transition: border-color 0.2s;
  width: calc(var(--theme-switcher__height) * 2 + var(--theme-switcher__gap) * 2);

  /* Switch */
  &::after {
    background-color: var(--color--slate);
    border-radius: 50%;
    content: '';
    display: block;
    height: calc(var(--theme-switcher__height) - var(--theme-switcher__gap));
    left: var(--theme-switcher-switch__left);
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    transition: left 0.2s;
    width: calc(var(--theme-switcher__height) - var(--theme-switcher__gap));
  }

  &:hover,
  &:focus-visible {
    border-color: var(--color--yellow);
    outline: none;
  }
}

And now for the icons...

.theme-switcher__icon {
  aspect-ratio: 1 / 1;
  background-color: var(--body__color);
  display: block;
  height: 60%;
  mask-position: center;
  mask-repeat: no-repeat;
  mask-size: contain;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  transition: left 0.2s;
}

.theme-switcher__icon--sun {
  left: calc(var(--theme-switcher__height) / 4 + var(--theme-switcher__gap) / 2);
  mask-image: url('https://res.cloudinary.com/dwfh3s7bs/image/upload/v1765314554/sun_rweodg.svg');
}

.theme-switcher__icon--moon {
  right: calc(var(--theme-switcher__height) / 4 + var(--theme-switcher__gap) / 2);
  mask-image: url('https://res.cloudinary.com/dwfh3s7bs/image/upload/v1765314358/moon_k3gcai.svg');
}

There are lots of ways to handle svg icons, but using a mask for single color icons is simplicity I can get behind. Otherwise, this is just nudging the icons into place.

With the markup and style set, let's add the behavior...

Onward to the Javascript

First, let's get the elements we need, and if there's no button we bail.

const btn = document.querySelector('.theme-switcher');
const root = document.documentElement;
if (!btn) return;

We'll use local storage so we can remember the user's preference as they go through the site. For now, let's get the system preference and set the intialTheme to that.

const storageKey = 'theme';
const savedTheme = localStorage.getItem(storageKey);
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
  ? 'dark'
  : 'light';
const initialTheme = savedTheme || systemTheme;

Next we'll set up a couple of functions to handle dark or light mode...

 function setDarkMode(hasLocalStorage = true) {
  btn.setAttribute('aria-label', 'Switch to light theme');
  root.setAttribute('data-theme', 'dark');
  if (hasLocalStorage) localStorage.setItem(storageKey, 'dark');
}

function setLightMode(hasLocalStorage = true) {
  btn.setAttribute('aria-label', 'Switch to dark theme');
  root.setAttribute('data-theme', 'light');
  if (hasLocalStorage) localStorage.setItem(storageKey, 'light');
}

In these, we update the aria-label, data attibute, and local storage.

Next up, we'll create a function to handle the click event, that will set the theme accordingly.

function onClick() {
  btn.setAttribute('aria-pressed', btn.getAttribute('aria-pressed') !== 'true');
  (root.getAttribute('data-theme') === 'dark')
    ? setLightMode()
    : setDarkMode();
}

Then, we'll set the initial theme on load, and add the event listener for the click event.

(initialTheme === 'dark')
  ? setDarkMode(false)
  : setLightMode(false);

btn.addEventListener('click', onClick);

Lastly, I didn't want FOUC (Flash Of Unstyled Content) when the page loads, and the user's settings dictate light mode. So this little bit just adds the transition property after eveything is painted to the screen.

requestAnimationFrame(() => {
  root.style.setProperty('--theme-switcher-switch__transition', "left var(--transition-dur)");
});

Putting a Bow on It

That's it. An easy accesibility win. I must admit, this is a whole lot easier on a simple blog site. Adding this to a large marketing site requires a ton of design work and upfront planning. Totally doable, just much harder.