Alan Shortis

Detect, set and save Dark Mode

Dark Mode is mainstream. Supporting it is reasonably easy, but what about giving users the option to contradict their OS settings and remembering that choice?

The media query

The prefers-color-scheme media query will tell us if the user has set a preferred colour scheme in their OS. Possible values are no-preference, light and dark.

For the purposes of this demo we are only going to be looking for dark, and taking anything else as the default (for both the OS setting and the design of our site).

The simplest dark mode

Detecting the preferred colour scheme is extremely straightforward using a media query:

body {
  background-color: white;
  color: black;
}

@media (prefers-color-scheme: dark) {
  body {
    background-color: black;
    color: white;
  }
}

This is the simplest possible dark mode implementation, setting alternative background and font colors based on the result of the media query. It can be further refined using CSS custom properties (variables):

:root {
  --background: white;
  --type: black;
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: black;
    --type: white;
  }
}

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

A more flexible approach

Using JavaScript to detect the preferred colour scheme then using alternative styling in CSS is an approach that makes it possible to defy the result of the media query and set the colour scheme programatically.

Prepare CSS

Still using CSS custom properties, we can redefine colours that need to change based on a class on the body:

:root {
  --type: #383735;
  --background: #f7f3e9;
  --highlight: #dd6969;
  --mid: #787671;
}

:root body.dark {
  --type: #f7f3e9;
  --background: #383735;
}

It's standard to set global variables inside :root, and since this is just a selector like any other we can select body.dark below. Toggling the class will change the colours assigned to the variables which are used in our styles.

We only redefine the type and background colours as our design has a highlight and mid-grey colour that works with either the light or dark colour scheme.

Set the scheme in JavaScript

Media Queries aren't just for CSS, they can be used in JavaScript with window.matchMedia. With this, we can conditionally add a class to the body tag:

function setInitialScheme() {
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    document.bodyclassList.add('dark');
  }
};

Change the scheme manually

Now that we have control of the color scheme in JavaScript, we can change it programatically. First, add buttons to make the change:

  <button type="button" class="js--change-scheme" data-scheme="light"></button>
  <button type="button" class="dark js--change-scheme" data-scheme="dark"></button>
  • Using a button instead of any kind of other element with a click handler keeps your site more semantic and accessible.
  • Set the type attribute to 'button' - by default, all button elements with no type are 'submit'.
  • The class dark on the button used to set the dark colour scheme is only to style this button differently. It has nothing to do with setting the dark colour scheme.
  • The class js--change-scheme is used to select the buttons in JavaScript. Using a dedicated class for JS selectors makes it easier to see that this element has an action and avoids sharing a class name between styles and JS that have nothing to do with one another.
  • The data-scheme attribute determines which colour scheme this button should activate.

Now we have the buttons, we need to handle their clicks. First, write a function that toggles the class on the body tag to change the color scheme:

function changeScheme(scheme) {
  if (scheme === 'dark') {
    classList.add('dark');
  }
  else {
    classList.remove('dark');
  }
}
  • This function takes an argument of scheme. This will be the value of the data attribute on each of our toggle buttons.
  • It checks if the value of scheme is 'dark', and if it is it adds the dark class to the body.
  • For any other colour scheme (or lack of) it'll remove the class from the body.

Now to handle clicking the buttons to run this function:

const schemeToggle = document.querySelectorAll('.js--change-scheme');

schemeToggle.forEach(button => {
  button.addEventListener('click', () => {
    changeScheme(button.dataset.scheme);
  });
});
  • Select all elements with the js--change-scheme class.
  • For each selected element, add a click event listener.
  • When clicked, call the changeScheme function and pass the value of the data-scheme attribute as an argument.

Remember the chosen scheme

What we have so far is pretty good - set the colour scheme based on the OS settings but allow an override. This can be improved by remembering the choice of scheme using localStorage and applying that when the site loads.

First, set the preferred scheme in localStorage when it's changed using the buttons by updating the changeScheme function:

function changeScheme(scheme) {
  if (scheme === 'dark') {
    classList.add('dark');
    localStorage.setItem('as-scheme', 'dark');  } else {
    classList.remove('dark');
    localStorage.setItem('as-scheme', 'light');  }
}

When the scheme is changed we set our local storage to the value of the selected scheme, either dark or light. We never actually check if the saved colour scheme is light, but by setting that specifically now we have the option to use it at a later date.

Next, we can check if this local storage has a value when we set the initial scheme:

const storedScheme = localStorage.getItem('as-scheme');
function setInitialScheme() {
  if (storedScheme) {    classList.add(storedScheme);    return;  }  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    document.bodyclassList.add('dark');
  }
};
  • Get the stored colour scheme with localStorage.getItem() using the same name we set when changing scheme.
  • Check if there is a stored colour scheme, if there is set the class on the body and return.
  • Now, if no scheme has been set in localStorage, check the preferred colour scheme with the media query.

Doing it in this order sets a bit of a hierarchy for the scheme to use:

  1. The scheme specifically chosen for this site.
  2. The scheme preferred in the OS setting.
  3. The default scheme for the site.

The end result

Now we've put all of these parts together, we have a site that can handle dark mode if set in the OS settings but also allows the user to have what they want.

Everything we have covered (plus a little more styling) is included in this pen. Saving the preferred scheme with localStorage works even in this embedded iFrame:

This solution is totally vanilla. No JS frameworks, libraries, CSS-in-JS or pre-processors are needed to get it working. You could use these things to make this a little easier to manage if you work on a large app and/or part of a team.

For instance, Styled Components includes ThemeProvider, which makes the contents of a JSON theme file avaialble to all styled components via props. Mainintaining a pair of themes (one light, one dark) and providing the correct one based on the media query/local storage would mean your CSS needs zero adjustment, and you could drop in as many themes as you like. All that's needed is that your theme file is very carefully defined.

For an even more stripped back approach, you could use CSS filters. It requires no consideration of alternative colour schemes whatsoever, but it also means you relinquish some control over the end result.