DEV Community

Ayc0
Ayc0

Posted on • Updated on

Light/dark mode: system mode + user preferences

In the previous posts, we saw:

  • how to use CSS variables to adapt the display to user system preferences,
  • how to use JS to toggle between light/dark mode.

But if you want to provide a way for your users to pick light/dark and still provide a way to also follow their native system, you'll need something else.

This is what this article will tackle.

⚠️ Warning, this is going to be more advanced than the previous parts

The logic

You’ll have to be able to handle 4 different configurations:

  • the user picked "light mode"
  • the user picked "dark mode"
  • the user picked "system mode" and their system is in light
  • the user picked "system mode" and their system is in dark

You have 2 possibilities for dealing with this:

  • 1 variable that can be light/dark/system and then within the CSS/JS have a way to get the "visual theme" from the system mode
  • 2 variables:
    • user choice: light/dark/system
    • applied mode: light/dark

The second method is a bit more complex to set up, but easier to reason with. And also it will match the CSS done in our previous part.

The CSS

As the CSS only deals with the visual appearance, we'll only have to care about the applied mode: light/dark.

The easiest is to apply a data attribute to the html light/dark. Also, as we chose the 2nd method with 2 distinct sets of variables, we only have to deal with light/dark. Dealing with the system will be done by another tool. So we don't have to use media queries.

Note: I'd still recommend setting the :color-scheme to light and dark for native inputs.

The CSS is still fairly simple (and the exact same one as before):



:root[data-applied-mode="light"] {
  color-scheme: light;
  --text: black;
  --background: white;
}
:root[data-applied-mode="dark"] {
  color-scheme: dark;
  --text: white;
  --background: black;
}


Enter fullscreen mode Exit fullscreen mode

The JS

We’ll have to store the user preference for future visits to the website. You can do that with the method you prefer:

  • localStorage (if everything is done in the frontend)
  • cookie (if you want to have access to it from the backend)
  • remote database (if you want to apply the same theme to multiple devices)

If you store the preferences in a remote database, I'd still recommend to double save it in a cookie/localStorage, because we'll see later how to avoid blinks when loading the pages. And this needs synchronous access to the stored value.

I'm gonna stick with localStorage here, because it's the easiest to deal with, but it doesn't really matter for this example.

Reading and writing the user preference

We can use this couple of function as first class getters/setters of the user preference:



function getUserPreference() {
  return localStorage.getItem('theme') || 'system';
}
function saveUserPreference(userPreference) {
  localStorage.setItem('theme', userPreference);
}


Enter fullscreen mode Exit fullscreen mode

Translating the user preference in the applied mode

Now that we have a way to get the saved user preference, we need a way to translate it to an applied mode.

The equivalence is simple:

  • the user picked "light mode" => light
  • the user picked "dark mode" => dark
  • the user picked "system mode" and their system is in light => light
  • the user picked "system mode" and their system is in dark => dark

The complicated part relies on the last 2 possibilities. Before we were using CSS media queries to handle this. Fortunately we can query CSS media queries with JS: matchMedia(<media query>).matches will return true/false depending on whether or not the browser is matching this media query:



function getAppliedMode(userPreference) {
  if (userPreference === 'light') {
    return 'light';
  }
  if (userPreference === 'dark') {
    return 'dark';
  }
  // system
  if (matchMedia('(prefers-color-scheme: light)').matches) {
    return 'light';
  }
  return 'dark';
}


Enter fullscreen mode Exit fullscreen mode

Setting the applied mode

As we only used an attribute on the html, applying only corresponds to setting the attribute on it.

This leaves us with this function:



function setAppliedMode(mode) {
  document.documentElement.dataset.appliedMode = mode;
}


Enter fullscreen mode Exit fullscreen mode

Assembling the whole ensemble

Now that we have all the elements, this is basically like legos: we need to assemble everything.

You still need to define 2 things:

  • an input that will trigger the rotation of your user preferences,
  • a function that will return the next preference based on the current one.

But then, you can do the following:



const themeToggler = document.getElementById('theme-toggle');
let userPreference = getUserPreference();
setAppliedMode(getAppliedMode(userPreference));

themeToggler.onclick = () => {
  const newUserPref = rotatePreferences(userPreference);
  userPreference = newUserPref;
  saveUserPreference(newUserPref);
  setAppliedMode(getAppliedMode(newUserPref));
}


Enter fullscreen mode Exit fullscreen mode


Note:
If you don't want any blink when users will load the page (seeing an empty white page when reloading the page for instance while they picked a dark mode for your website), it's important that this JS is executed in a blocking way, so that browsers won't render the html/css without having first computed this JS and applied the data attribute on the html. See:


Note 2:
The system mode we built here only resolves the theme when system is picked. But it won’t follow the system’s value in real time.

Top comments (4)

Collapse
 
rei7 profile image
rei

thanks for writing this, exactly what i wanted.
so basically, if we want the 'system' option, then we can't have a literal media query in css, @media ('prefers-color-scheme: dark') would override the whole thing, right? everything now is controlled by js.
and as you pointed out at the end, this approach wouldn't respond in real time when set to 'system', anyway to mediate that? i guess if we must have that, that's another layer of complexity.
thanks again.

Collapse
 
ayc0 profile image
Ayc0

so basically, if we want the 'system' option, then we can't have a literal media query in css, @media ('prefers-color-scheme: dark') would override the whole thing, right?

If you just want a system mode, you don't need all that. Also if you want forced light, forced dark, and system, when in system mode you can use those media queries are those represent your users' system indeed

Collapse
 
ayc0 profile image
Ayc0

and as you pointed out at the end, this approach wouldn't respond in real time when set to 'system', anyway to mediate that? i guess if we must have that, that's another layer of complexity.

This article is about handling all 3 modes "forced light", "forced dark", and "system". This is by definition a new layer of complexity. BUT

  • if you just want a system mode, you can check dev.to/ayc0/light-dark-mode-the-si...
  • if you indeed need those 3 modes, the reason why it's driven by JS is because of the forced modes. But you can tweak your CSS to your desire to have the output you want. For instance you can add a new data attr for the saved mode and then use media queries in CSS:
:root[data-base-mode="light"] {
  color-scheme: light;
  --text: black;
  --background: white;
}
:root[data-base-mode="dark"] {
  color-scheme: dark;
  --text: white;
  --background: black;
}

@media ('prefers-color-scheme: light') {
  :root[data-base-mode="system"] {
    color-scheme: light;
    --text: black;
    --background: white;
  }
}

@media ('prefers-color-scheme: dark') {
  :root[data-base-mode="systen"] {
    color-scheme: dark;
    --text: white;
    --background: black;
  }
}

body {
  color: var(--text);
  background: var(--background);
}
Enter fullscreen mode Exit fullscreen mode

You can also use matchMedia().addEventListener() like in dev.to/ayc0/light-dark-mode-react-... to have the JS live reload to your system changes (if you need to also sync some JS components)

Thread Thread
 
rei7 profile image
rei

yo thanks so much for taking the extra time to make this follow-up reply.
i actually read all 7 posts in this series. i'd say i gained valuable overall knowledge about theming, especially after reading the react one, it's thorough and complete.
i think the reason it was confusing to me at first, it's because, the option 'system' is not actually a theme, it's not on the same level as 'light' and 'dark', in the end it needs to resolve to 'light' or 'dark'(what you called 'visual theme').