How to add a Dark Mode to your Website

Bender from Futurama meets his golden doppelganger in an alternate universe
Credit: whoever owns Futurama these days.

Do your users feel like they are staring at the sun when perusing your website? If so, you might want to consider adding a dark mode or dark theme as an option. I'm going to teach you how to do it using nothing but HTML, CSS, and vanilla JavaScript. What's more, by the end of this article, you should be able to implement as many new themes as your creative heart desires.

Get to the Point, Ulises

This is not a cooking blog; I'll get right to the point.

Repository

Clone this repository if you want to get your hands dirty right away. Run npm run start in your terminal and it will open up a little Parcel playground so you can see it in action.

The Problem

  1. We want to define at least two themes: a default theme and a 'dark' theme
  2. Users need to be able to dynamically switch between these themes
  3. The user's preferred theme should persist even after the user leaves our site
  4. We want the theme to load as soon as possible to avoid a janky loading experience

The Solution

We are going to define one CSS class per theme, and apply the current theme to the body tag. This way, all child elements in the body will inherit the appropriate styles. When the user chooses a new theme, we'll apply the relevant class to the body. We'll use the browser's LocalStorage API to store the user's choice.

If you want to use some prewritten HTML markup, check out the repo I mentioned above.

Define the Themes

Enter: CSS Variables. In case you're not familiar with them, CSS Variables are similar to JS variables in that they are a great way to share or reuse properties in your code, but I also love how they are scoped so intuitively. For example, you can declare a variable at the root level, but then reassign a value to that variable within a class, and the new value will only be available to that class element and its descendents, without affecting the rest of the stylesheet! In other words, it's hard to break something just by using CSS Variables.

One more thing about CSS Variables: they are compiled at runtime (i.e., the browser). If you have used SASS before, you might be inclined to use SASS variables instead. While SASS variables certainly are powerful tools, they are compiled at build time, which means that they will not know when the user has chosen a new theme.

Choose Your Themeing Strategy

Maybe you're just going to change the page's background and font colors. Or, perhaps you have something grander in mind. Either way, you need to decide on a reusable pattern. Google's material.io is one popular method for defining themes, and I recommend it if you've never designed a full theme.

For the sake of simplicity in this tutorial, we'll stick to background and text color.

The CSS

:root {
  --red: #f26463;
  --orange: #ff640f;
  --blue: #24748f;
  --yellow: #f1dd6d;
  --white: #fafafa;
  --black: #2a2a2a;
  --surface: var(--blue);
  --text-on-surface: var(--white);
  --shadow: 0 0 5px rgba(0, 0, 0, 0.5);

  font-size: 16px;
}

.theme-default {
  --background: var(--white);
  --text-color: var(--black);
}

.theme-dark {
  --background: var(--black);
  --text-color: var(--white);
}

body {
  background-color: var(--background);
  color: var(--text-color);
}

At the :root level, we've defined some properties that we want to reuse throughout the stylesheet. However, if we wanted to use a different shade of white in each theme, for example, we could change it up like so:

:root {
  --red: #f26463;
  --orange: #ff640f;
  --blue: #24748f;
  --yellow: #f1dd6d;
  --black: #2a2a2a;
  --error: var(--red);
  --surface: var(--blue);
  --text-on-surface: var(--white);
  --shadow: 0 0 5px rgba(0, 0, 0, 0.5);

  font-size: 16px;
}

.theme-default {
  --white: #fafafa;
  --background: var(--white);
  --text-color: var(--black);
}

.theme-dark {
  --white: #f0f0f0;
  --background: var(--black);
  --text-color: var(--white);
}

body {
  background-color: var(--background);
  color: var(--text-color);
}

.some-element {
  border: 1px solid var(--white);
}

Super easy! Hopefully by now you've noticed that you can define any number of themes this way.

Switching Themes

On to the JavaScript. Have no fear, however; we're just going to define a handful of little functions to handle theme-switching.

So you can focus on the JavaScript, go ahead and use this stylesheet.

1) Open the Menu

First, we add class 'open' to the menu element so it becomes visible. The, we tell the browser to remove class 'open' from the menu element when the users mouse is no longer over the menu.

// logic.js
const profileButton = document.querySelector('.profile-button')
const menu = document.querySelector('.menu')

profileButton.addEventListener('click', () => {
  if (!menu.classList.contains('open')) {
    menu.classList.add('open')
  }
})

menu.addEventListener('mouseleave', e => {
  if (e.target.classList.contains('open')) {
    e.target.classList.remove('open')
  }
})

2) Define getTheme() and setTheme() Functions

Let's define a mutable variable currentTheme, which will eventually be a string that represents the user's choice. Since our code hasn't retrieved the theme, yet, initialize currentTheme without a value.

Then, we will define two functions: getTheme() and setTheme().

The function getTheme() will just retrieve the value associated with the key 'theme' in the browser's localStorage.

The function setTheme() takes one parameter–a string we'll call newTheme. If there is no newTheme (the user hasn't chosen a custom theme), we will fallback to 'default'.

  1. Sets the body class to the the current theme
  2. Saves the newTheme to variable currentTheme
  3. Iterates over the li elements that represent the theme choices, and applies or removes a class 'active-theme'
  4. Updates the 'theme' entry in localStorage to newTheme

Note: We'll define toggleActiveClass() in the next step.

// logic.js
const body = document.querySelector('body')
const themeEls = document.querySelectorAll('.theme-li)
const profileButton = document.querySelector('.profile-button')
const menu = document.querySelector('.menu')

let currentTheme

function getTheme() {
    return localStorage.getItem('theme')
}

function setTheme(newTheme = 'default') {
  if (currentTheme === newTheme) return

  body.classList.value = `theme-${newTheme}`

  currentTheme = newTheme

  themeEls.forEach(el => toggleActiveClass(el))

  localStorage.setItem('theme', newTheme)
}

...

3) Define toggleActiveClass()

In the HTML markup, you'll notice that I've chosen to label each .theme-li element with a data-theme attribute that points to the corresponding theme.

Function toggleActiveClass() takes a DOM element, checks looks at the data-theme attribute, and either adds or removes the '.active-theme' class. We do this so we can easily show the user which theme is current active.

// logic.js
...

function toggleActiveClass(el) {
  if (el.dataset.theme === currentTheme) {
    el.classList.add('active-theme')
  } else if (el.classList.contains('active-theme')) {
    el.classList.remove('active-theme')
  }
}

...

4) Get Theme when the Browser Loads

We want the browser to look for and apply the theme as soon as possible. You might be tempted, as I initially was, to listen for the window's load event, but there is a better way: the DOMContentLoaded event.

The DOMContentLoaded event fires when the initial HTML document has been completely loaded and parsed, without waiting for stylesheets, images, and subframes to finish loading.
MDN Docs
// logic.js
...

window.addEventListener('DOMContentLoaded', () => setTheme(getTheme()))

...

That's it! Or is it?

You might notice when you refresh the page that you see a flash before the user's theme is applied. If we peek at our DevTool's Network tab, we can easily see why:

A screenshot of the network tab shows that our logic.js file is loaded after our styles

The browser is stopping to parse our styles before it even loads the logic.js file. There is a very easy fix for this. In index.html, add this to the head tag:

<link
  rel="preload"
  href="./logic.js"
  as="script"
  type="application/javascript"
/>

Suddenly, like magic:

A screenshot of the network tab after adding our link tag with the rel attribute set to "preload" shows that logic.js loads before our styles

And upon refreshing, no more ugly flashing.

Final Thoughts

Themes are cool. Check out my theme switcher in the header above.

Thanks for reading this post. If you found it helpful, please share it!