Define dark mode for both a class and a media query, without repeat CSS custom properties declarations, and allow users to switch between color modes

Viewed 1373

I have:

body { background: white; }

To display dark mode, I use .dark class:

.dark body { background: black; }

And to detect if user has their OS set to use dark theme, we have prefers-color-scheme:

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

And then we have the idea of DRY (Don’t Repeat Yourself) programming. Can we define dark mode without repeating CSS properties declarations, and in the process, allow users to switch between the color modes via JS?

With the above example, the .dark class and the media query are copies of each other.

What I've done so far

Skipped prefers-color-scheme in CSS and used:

body { background: white; }
.dark body { background: black; }

Then via JS, detect their settings and adjust the

if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
     document.getElementsByTagName('html')[0].classList.add('dark');
}

The problem with this approach is it doesn't use prefers-color-scheme in CSS.

While I can add:

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

It won't let me toggle the color schemes via JS because I can't cancel prefers-color-scheme: dark for a user who has dark set in their OS preferences.

What is the 2022 way of solving this?

5 Answers

Let's analyze it.

We definitely cannot put the dark mode styles only inside the media query, since there will be no way to apply it via in-site choice. So we want the styles to be under a certain class (dark) with no media query (so that the class can be toggled via JS), but the media query should somehow pull the same styles even without that class, and without repeating the styles. We probably also want a light class to override the media query if the user chooses to.

The perfect solution here would be the abandoned @apply at-rule which could allow reusing of a bunch of styles under a single variable. But this was, well, abandoned.

The standard proposal for reusing a bunch of styles is using mixins on a CSS preprocessor (so it's not repeated on the project's source code but it's repeated on the compiled CSS). If all properties are applied to the <body> element or a few elements then I believe that's the way to go. However you asked for a solution without preprocessors.

The other way would be to use the media query in the JS rather than in the CSS itself, and toggle the class accordingly. This method would handle even complicated themes with properties applied to many elements, but it may have a "Flash of unstyled content" until the JS takes effect. Anyway you already brought that up and asked for a solution that keeps the media query in the CSS itself.

You also asked to not use repeated CSS variables. Now, I'm not sure it would be possible without repeating anything, but pheraps we can reduce the repeatation to two "toggle" variables, regardless of the number of affected properties.

var root = document.documentElement,
  theme = window.getComputedStyle(root)
  .getPropertyValue('--light') === ' ' ? 'dark' : 'light';

document.getElementById('toggle-theme')
  .addEventListener('click', toggleTheme);

function toggleTheme() {
  root.classList.remove(theme);
  theme = (theme === 'dark') ? 'light' : 'dark';
  root.classList.add(theme);
}
/*
  "initial"ized variables are like undefined variables and will resolve to
  their fallback value.
  Whitespaced variables (the space is required) will serve as an empty value
  in chaining.
*/

@media (prefers-color-scheme: dark) {
   :root {
    --light: ;
    --dark: initial;
  }
}

@media (prefers-color-scheme: light) {
   :root {
    --dark: ;
    --light: initial;
  }
}

:root.dark {
  --light: ;
  --dark: initial;
}

:root.light {
  --dark: ;
  --light: initial;
}

#content {
  background-color: var(--dark, darkblue) var(--light, lightblue);
  color: var(--dark, white) var(--light, black);
  border: 5px solid var(--dark, blue) var(--light, yellow);
}
<div id="content">
  Hello, world!
  <button id="toggle-theme">Toggle theme</button>
</div>

Finally, I can think of another possible hack, though I really don't like it. The idea is to add two <div>s with the only purpose of inheriting styling one to another based on preferred/selected theme.

var root = document.documentElement,
  theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

document.getElementById('toggle-theme')
  .addEventListener('click', toggleTheme);

function toggleTheme() {
  root.classList.remove(theme);
  theme = (theme === 'dark') ? 'light' : 'dark';
  root.classList.add(theme);
}
.dark-styles {
  background: darkblue;
  color: white;
  border-color: blue;
}

.light-styles {
  background: lightblue;
  color: black;
  border-color: yellow;
}

:root.dark .light-styles {
  all: inherit;
}

@media (prefers-color-scheme:dark) {
   :root:not(.light) .light-styles {
    all: inherit;
  }
}

#content {
  border: 5px solid;
  border-color: inherit;
}
<div class="dark-styles">
  <div class="light-styles">
    <div id="content">
      Hello, world!
      <button id="toggle-theme">Toggle theme</button>
    </div>
  </div>
</div>

Below is a simple solution using CSS Variables, prefers-color-scheme media query and radio buttons. Here, the page detects OS dark/light theme and lets the user to change the theme by clicking on radio buttons without JavaScript. You can also trigger a click event on these radio buttons if you really need JavaScript.

/* Defines theme variables */

:root {
    --theme-light-color: #222222;
    --theme-light-background: #FFFFFF;
    --theme-dark-color: #DDDDDD;
    --theme-dark-background: #222222;
}

/* Defines body content styles */

body {
    margin: 0;
}

body>main {
    min-height: 100vh;
    color: var(--theme-main-color);
    background: var(--theme-main-background);
}

label[for="theme-light"] {
    color: var(--theme-light-color);
    background: var(--theme-light-background);
    cursor: pointer;
}

label[for="theme-dark"] {
    color: var(--theme-dark-color);
    background: var(--theme-dark-background);
    cursor: pointer;
}

/* IF theme = light THEN */

#theme-light:checked~main {
    --theme-main-color: var(--theme-light-color);
    --theme-main-background: var(--theme-light-background);
}

@media (prefers-color-scheme: light){
    :root {
        --theme-main-color: var(--theme-light-color);
        --theme-main-background: var(--theme-light-background);
    }
}

/* IF theme = dark THEN */

#theme-dark:checked~main {
    --theme-main-color: var(--theme-dark-color);
    --theme-main-background: var(--theme-dark-background);
}

@media (prefers-color-scheme: dark){
    :root {
        --theme-main-color: var(--theme-dark-color);
        --theme-main-background: var(--theme-dark-background);
    }
}
<!DOCTYPE html>
<html lang="en">
    <body>
        <!-- THEME SWITCH -->
        <input type="radio" id="theme-light" name="theme" hidden>
        <input type="radio" id="theme-dark" name="theme" hidden>
        
        <!-- BODY CONTENT -->
        <main>
            <label for="theme-light">Light</label>
            <label for="theme-dark">Dark</label>
            <p>Hello World!</p>
        </main>
    </body>
</html>

If you want to do this with ‍css‍, you can try the following method

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

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

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

You could achieve that by reversing the logic.

body { background: black; }

@media (prefers-color-scheme: light) {
  :root:not(.dark) body { background: white; }
}

You can keep advantage of the CSS media query without actually using it in the CSS itself by using JS.

For example, taking your .dark class from your code, you could use a MediaQueryList object:

const darkMode = window.matchMedia("(prefers-color-scheme: dark)");

And set up a toggle for the class list of the document:

// Initial setting
document.documentElement.classList.toggle("dark", darkMode.matches);

// Listener to changes
darkMode.addListener((event) =>
    document.documentElement.classList.toggle("dark", event.matches));

In that way you can focus in your CSS files on just defining the classes under the .dark class and you won't need to define it twice by using the media query. Also, doing it in that way you can setup settings for the user which would override the default OS Preference in case you want (for example saving it in a localStore, etc...)

Related