css dark-mode/light-mode - rethought

css dark-mode/light-mode - rethought

So, you read my last blog entry about "CSS, Dark-Mode and Custom Properties" and start to build for every website a light and a dark theme, to support your visitors. That's nice - and then you like your light theme more than the dark theme and your mobile phone switches between light and dark theme according to sun hours?
Or you don't care on your Windows desktop about the light/dark global theme, but you do want to use the opposite theme of this website you built?
Or you are proud of both themes and want every user to be able to switch between these themes, even if he does not know about light-mode or dark-mode settings?

In this post, we will give the user the power to choose between a dark and a light theme, but preserve the media-query magic, for those who don't care. At the end of this page, there is a gist with a working example.

UPDATE (12 Feb. 2020): Safari did not work with my example, updated code and text below.

the built-ins

With media queries you can meet the user preference:

@media (prefers-color-scheme: dark) {
  /* dark theme adjustments */
}

or if your colors are darkish by default:

@media (prefers-color-scheme: light) {
  /* light theme adjustments */
}

and if you use CSS custom properties the theming itself (overwriting custom properties) is not that big issue, but designing a light and a dark theme still will be.

the way around

You start your theming in a data-attribut'ish namespace on your html element. For example data-theme="light" and data-theme="dark". But don't forget to set one of these themes be your default, so send on initial request the data-attribute along, or just use one override. My advise is to deliver a basic theme which does not depend on the data-attribute, since we will alter that attribute with JavaScript and if a plattform goes wild and set the attribute value for some reason to undefined your user will be in trouble.

<!doctype html>
<html data-theme="light">
    <body>...</body>
</html>
:root {
  /* colors taken from here: https://color.adobe.com/de/Neutral-Blue-color-theme-10152035*/
  --body-background: #193441;
  --text-color: #FCFFF5;
  --another-color: #91AA9D;
}

html[data-theme="light"] {
  --body-background: #FCFFF5;
  --text-color: #3E606F;
  --another-color: #D1DBBD;
}

why html element?

We want to set the chosen theme, as soon as possible. In <head> context, the <body> element is not yet available. By manipulating the <html> element from the <head> element, the user will instantly get the theme he chosed, without a flash of the other theme.

the javascript bit

After crafting a light and a dark theme, you may want to let your user choose, which theme he want to use:

if (window.confirm('Hello, this website provides another theme for you, wanna try it?')) {
  document.documentElement.dataset.theme="light";
  window.localStorage.setItem("theme", "light");
} else {
  window.localStorage.setItem("theme", "dark");
}

On load put this JavaScript in your <head> if the user already chosen one theme, css rendering will directly use this setting:

<script>
    var theme = window.localStorage.getItem("theme");
    if (theme) {
        document.documentElement.dataset.theme = theme;
    }
</script>

Now, your user can choose which theme he'd like. If you switch between themes, you should also provide a "go back" button, like every operating system does when you fiddle on your screen settings ;-)

no more media query magic?

Right now we eliminated the media query goodness out of our website, but that's not the idea.

With window.matchMedia we can listen for changes and check media queries in our JavaScript (yeah!).

    var mqDark = window.matchMedia('(prefers-color-scheme: dark)');
    var mqLight = window.matchMedia('(prefers-color-scheme: light)');
    
    function checkTheme(event) {
        if (event.matches) {
            if (event.media.includes('dark')) {
                document.documentElement.dataset.theme = 'dark';
            }
            if (event.media.includes('light')) {
                document.documentElement.dataset.theme = 'light';
            }
        }
    }
    
    try {
      mqDark.addEventListener('change', checkTheme);
      mqLight.addEventListener('change', checkTheme);
    } catch (maybeSafari) {
      try {
        mqDark.addListener(checkTheme);
        mqLight.addListener(checkTheme);
      } catch (dontknow) {
        // not supported…
      }
    }
UPDATE: mdn tells you to use mqDark.addListener(callbackFn); but that did not work on Chrome and Firefox, so I used mqDark.addEventListener('change', callbackFn); - mdn tells that too: "The new version of the MediaQueryList interface inherits methods from its parent interface, EventTarget."
Safari does not support a new version of MediaQueryList so you need to wood-hammer around this. See: https://stackoverflow.com/a/60000747

But the callbacks don't fire on initial execution, so we need to add another check to that:

    var theme = window.localStorage.getItem('theme');
    if (mqDark.matches && !theme) {
        // don't store in localStorage because it is not a user setting
        document.documentElement.dataset.theme = 'dark';
    }

    if (mqLight.matches && !theme) {
        // don't store in localStorage because it is not a user setting
        document.documentElement.dataset.theme = 'light';
    }

The theme check is our handbrake, so a user chosen theme (from the window.confirm dialog before) don't get overwritten.

the whole picture

In this gist, I put the whole working code.

<!DOCTYPE html>
<html lang="de">
<head>
    <title>I'm in the mode</title>
    <meta charset="utf-8">

    <meta name="description" content="Sometimes I ran…">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://fonts.googleapis.com/css?family=Big+Shoulders+Text&display=swap" rel="stylesheet"> 
    <script>
        (function () {
            var theme = window.localStorage.getItem("theme");
            if (theme) {
                document.documentElement.dataset.theme = theme;
            }
        }());
    </script>

    <script>
        (function() {
            var mqDark = window.matchMedia('(prefers-color-scheme: dark)');
            var mqLight = window.matchMedia('(prefers-color-scheme: light)');
            var theme = window.localStorage.getItem('theme');

            function checkTheme(event) {
                if (event.matches) {
                    if (event.media.includes('dark')) {
                        document.documentElement.dataset.theme = 'dark';
                    }
                    if (event.media.includes('light')) {
                        document.documentElement.dataset.theme = 'light';
                    }
                }
            }

            if (mqDark.matches && !theme) {
                // don't store in localStorage because it is not a user setting
                document.documentElement.dataset.theme = 'dark';
            }

            if (mqLight.matches && !theme) {
                // don't store in localStorage because it is not a user setting
                document.documentElement.dataset.theme = 'light';
            }

            if (!theme) {
                try {
                    mqDark.addEventListener('change', checkTheme);
                    mqLight.addEventListener('change', checkTheme);
                } catch (maybeSafari) {
                    try {
                        mqDark.addListener(checkTheme);
                        mqLight.addListener(checkTheme);
                    } catch (dontknow) {
                        // not supported…
                    }
                }       
            }
        }());
    </script>
    <script>
        (function() {
            var theme = window.localStorage.getItem('theme');

            function revokeTheme() {
                if (!window.confirm('Do you want to stay with that theme?')) {
                    window.localStorage.removeItem('theme');
                    document.documentElement.dataset.theme = null;
                }
            }

            if (!theme) {
                if (window.confirm('This page offers a "dark" theme, you want to try it?')) {
                    window.localStorage.setItem('theme', 'dark');
                    document.documentElement.dataset.theme = 'dark';
                    window.setTimeout(revokeTheme, 3000);
                } else {
                    window.localStorage.setItem('theme', 'light');
                    document.documentElement.dataset.theme = 'light';
                }
            }
        }())
    </script>
    <style>
        :root {
            --bg-color: hsla(0, 0%, 90%, 1);
            --text-color: hsla(0, 0%, 20%, 1);
        }
        *,
        *::before,
        *::after {
            box-sizing: border-box;
        }
        html {
            width: 100vw;
            height: 100vh;
            max-width: 100%;
            min-height: 100%;
            display: flex;
            overflow-x: hidden;
            margin: 0;
            align-items: center;
            justify-content: center;
        }

        body {
            background-color: var(--bg-color);
            color: var(--text-color);
            font-family: 'Big Shoulders Text', cursive;
            transition: color .2s ease-in, background-color .2s ease-out;
        }
        .mode::after {
            content: "light";
        }

        html[data-theme="dark"] {
            --bg-color: hsla(0, 0%, 20%, 1);
            --text-color: hsla(0,0%, 80%, 1);
        }
        html[data-theme="dark"] .mode::after {
            content: "dark";
        }
    </style>
</head>
<body>
    <section class="hero">
        <h1>Welcome to the <span class="mode"></span> mode</h1>
    </section>
</body>
</html>

Article Image from Osman Rana via unsplash and ghost ❤.