A guide to implemen­ting dark modes on websites

Previously I wrote about how to decide to add a dark mode to a product and what to consider when designing a dark mode. I made some comments about how I implemented it on this website. I made several improvements after that, so I’m sharing my learnings here.

Adding a dark mode is basically adding a theme. The principles are the same for adding a light mode to a dark website or alternative styling based on user-defined variables, the time of year or holidays.

I added theming with a mix of and CSS. In this post I’ll go step by step into the details of how I did it and what I learned.

Setup

The themes are activated by CSS classes on the root element, <html>. When the page is loaded, I want to apply the theme that most likely suits the visitor (you!) best. After all, most people don’t like configuring websites before they can read a blog post, so the the whole theming feature would likely remain unused otherwise. So I have to make a guess about what the visitor wants and expects. I do that in this order:

  1. I assume people don’t want the theme to change when they navigate between pages. So if the page loaded isn’t the first page they visit, I want to use the theme that was used before.
  2. If it’s the first page they view on my site, their browser may be able to tell their preference.
  3. If no preference is available, we can base the choice based on whether it’s day or night.

I also want to react to changes:

  • When the theme is changed in one browser tab, all other tabs with the website should change with it
  • When visitors change their OS from light to dark mode or vice versa, the website should react to that.

Turning that logic into :

1
2
3
4
5
6
7
8
9
(function initializeTheme(){
  syncBetweenTabs()
  listenToOSChanges()
  enableTheme(
    returnThemeBasedOnLocalStorage() ||
    returnThemeBasedOnOS() ||
    returnThemeBasedOnTime(),
    false)
}())

Of course, visitors should be able to manually select a theme if my guess is wrong. Finally, I added a transition for when the theme changes. This is also done with a CSS class added to the root element.

That’s the basic setup, now let’s dive into the details!

Saving and loading state

When a visitor navigates from page to page, the theme shouldn’t change. That’s why I save the state of the theme selection, so it can be loaded by the next page. After having considered some alternatives (see below), I’ve landed on saving the selected theme to local storage.

Every time a page is loaded, in the current or a new tab, it checks if a theme was set previously. Because the preference for a light or dark theme can change during the day, with every change, I add a time stamp to the saved setting. Only when the state was saved less than two hours ago, it’s applied:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function returnThemeBasedOnLocalStorage() {
  const pref = localStorage.getItem('preference-theme')
  const lastChanged = localStorage.getItem('preference-theme-last-change')
  let now = new Date()
  now = now.getTime()
  const minutesPassed = (now - lastChanged)/(1000*60)

  if (
    minutesPassed < 120 &&
    pref === "light"
  ) return 'light'
  else if (
    minutesPassed < 120 &&
    pref === "dark"
  ) return 'dark'
  else return undefined
}

When the visitor manually changes the theme in one tab, all other tabs should change with it. To achieve that, I added an event listener for changes to local storage. The cool thing is that that event listener only fires in other tabs. Browsers assume that the application is aware of changes in the active tab already. Thanks for pointing that out, Max Freundlich.

1
2
3
4
5
6
7
8
function syncBetweenTabs(){
  window.addEventListener('storage', (e) => {
    if (e.key === 'preference-theme'){
      if (e.newValue === 'light') enableTheme('light')
      else if (e.newValue === 'dark') enableTheme('dark')
    }
  })
}

Discarded solutions

We could use URL parameters to save the state, but that would mean the selected theme would be passed on to other people when links are shared to the pages. OS-level preferences are ignored when pages are opened that way, or via bookmarks.

The simplest solution for saving state during the session only is using the browser’s session storage. It’s the less known variant of local storage, except that it’s cleared when the session ends. The drawback of that is that if a page is opened in a new tab, it doesn’t know about the previously used theme.

Check for OS-level preferences

If we can’t choose a theme based on a saved state from a recent visit, we can check the OS’s setting. We can use the CSS media feature prefers-color-scheme. It can have one of three values:

  • dark
  • light
  • no-preference

As far as I know, the easiest or only way to check the visitor’s preference with is to test if one of the values matches:

1
2
3
4
5
6
7
8
9
function returnThemeBasedOnOS() {
  let pref = window.matchMedia('(prefers-color-scheme: dark)')
  if (pref.matches) return 'dark'
  else {
    pref = window.matchMedia('(prefers-color-scheme: light)')
    if (pref.matches) return 'light'
    else return undefined
  }
}

Choose a theme based on the time of day

The prefers-color-scheme feature has solid support on the evergreen desktop browsers and iOS 13 but Edge and several mobile browsers don’t support it yet. As a fallback, I want to apply the dark theme between sunset and sunrise. According to my analytics, most visitors comes during office hours, so I didn’t want to make this too advanced and just assume the sun sets at 20:00 and rises at 5:00. Every day of the year.

1
2
3
4
5
6
function returnThemeBasedOnTime(){
  let date = new Date
  const hour = date.getHours()
  if (hour > 20 || hour < 5) return 'dark'
  else return 'light'
}

Let visitors manually choose a theme

Despite my best efforts, my guess for what theme visitors want may be wrong. So I added a button for each theme to my page template. Because I only have a light and a dark theme, I hide the button of the currently active theme.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<html class="theme-light">
  <form class="theme-selector">
  <button
    aria-label="Enable light theme"
    aria-pressed="false"
    role="switch"
    type="button"
    id="theme-light-button"
    class="theme-button enabled"
    onclick="enableTheme('light', true)"
  >Light theme</button>
  <button
    aria-label="Enable dark theme"
    aria-pressed="false"
    role="switch"
    type="button"
    id="theme-dark-button"
    class="theme-button"
    onclick="enableTheme('dark', true)"
  >Dark theme</button>
  </form>
  <!--- Rest of the website --->
</html>

To make sure the buttons aren’t shown where is not supported, they’re hidden by default. My then unhides it on page load.

As you can see, there are two ARIA properties to make the buttons accessible. To be honest, I’m not sure how useful they are. The theming is all about styling that is irrelevant to most people with vision bad enough to need a screen reader. Then again, I can imagine that there are people with a visual impairment who do have a preference for one theme or the other and use a screen reader to compliment their visual abilities.

Style the page based on the selected theme

So I apply the theme by changing the classes on the root element, but what does exactly happen in CSS? I found that using CSS variables are great to make that switch. That way, I can change the variable once and have many components react to it. Combined with SCSS, you get something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$theme-light-text-color: #111;
$theme-dark-text-color: #EEE;

@mixin color($property, $var, $fallback){
  #{$property}: $fallback; // This is a fallback for browsers that don't support the next line.
  #{$property}: var($var, $fallback);
}

p{
  @include color(color, --text-color, $theme-light-text-color);
}
.theme-dark{
  --text-color: #{$theme-dark-text-color};
}

In this example, the light theme is used as a default. The interesting part is that when the CSS variable --text-color is not set, the fallback for it is used. When the class theme-dark is added to the root, the variable is defined and applied. Of course I didn’t come up with that trick myself. I recommend taking a look at Andy Clarke’s article about dark modes and this theming example for more details. There’s also also Wei Gao’s interesting approach to create a dark mode with blending modes.

Transition between themes

A transition between the themes makes switching less jarring. That can be straight-forward, but I already had elements with transitions. Their transition-durations are much shorter than the duration of the theme change. When elements change color at different paces, that looks almost as bad as without a transition. I described my workaround in Dark mode design considerations, so I’ll skip it here.

The actual theme switching

Putting all of the above together, I wrote this function for applying a theme:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function enableTheme(newTheme = 'light', withTransition = false, save = true){
  // Collect variables
  const root = document.documentElement
  let otherTheme
  newTheme === 'light' ? otherTheme = 'dark' : otherTheme = 'light'
  let currentTheme
  (root.classList.contains('theme-dark')) ? currentTheme = 'dark' : 'light'

  // Transitions aren't added on page load
  if (withTransition === true && newTheme !== currentTheme) animateThemeTransition()

  // Set the theme
  root.classList.add('theme-' + newTheme)
  root.classList.remove('theme-' + otherTheme)

  // Update the controls
  let button = document.getElementById('theme-' + otherTheme + '-button')
  button.classList.add('enabled')
  button.setAttribute('aria-pressed', false)
  button = document.getElementById('theme-' + newTheme + '-button')
  button.classList.remove('enabled')
  button.setAttribute('aria-pressed', true)

  // Save the state
  if (save) saveToLocalStorage('preference-theme', newTheme)
}

Browser support

The solutions I described above use modern browser features, most notably the media query for prefers-color-scheme and CSS variables. I’ve also used some modern style . The CSS variables are key. Current browsers that support it, also support the other essential features. I added a CSS media query to only show the theme selection buttons in browsers that support those:

1
2
3
4
5
6
7
8
.theme-selector {
  display: none;
}
@supports ( (--a: 0)) {
  .theme-selector {
    display: block;
  }
}

It’s not a perfect check, because Safari started supporting CSS variables in version 9.1 and arrow functions only in version 10. We’re at version 13 now, so that’s not really an issues for my small audience.

Putting it all together

Because of the manual and automatic ways I want to support theme changes, the script got a bit larger than I expected. It’s not that complicated though—the real effort to implement a theme is still in designing it. So, again, make sure you really need a dark mode before adding it and that you have a well-made design ready.

You can find my complete script on Github.