Rasmus​.krats​.se

Light or dark?

Posted 2022-05-16 22:11. Tagged , , , , .

I’ve had a light and a dark theme on this site since I switch from python to rust. But until now I have only used css @media selection to enable the dark theme, so it hasn’t been very discoverable. If you have a browser that supports the prefers-color-scheme query and you have found that setting and enabled dark mode, you have seen this site in the dark theme (and may not know that it had a light theme), otherwise you have seen the site in the light theme (and not known about the dark).

So now this site have a “theme” button in the header. By clicking it you toggle between the “device default”, “dark” and “light” theme. The default is the “device default”, which is the same as before I introduced the button, either dark or light based on browser preference. So people who have made an active choise in their browser get that choise on this site automatically.

So, how do I implement this? The changes is a PR for kaj/r4s on github. What follows in this post is a walk-through of the main parts.

The old way (well, what I did from january to may 2022) was to have css code like this:

body {
  background: #fdfbf9; /* paper */
  color: black;
  /* .. and lots more styles */
}

@media (prefers-color-scheme: dark) {
  body {
    background: #25082e; /* dark purlple */
    color: #fdfbf9;
    /* .. and overrides for every other color style */
  }
}

As far as I know, it’s not possible to change the prefers-color-scheme preference from a button in a web page. And anyway, I’d like to make it possible to switch in browsers that don’t have that preference. What I can easily do from a button is change a class attribute on the html element. So what I need is this:

/* All common and light-theme css */

html.theme-dark { /* All dark theme overrides */ }
@media (prefers-color-scheme: dark) {
    html:not(.theme-light) { /* All dark theme overrides _again_ */ }
}

Typing all the dark theme overrides twice is not really a problem, since I use sass, so the real code looks like this:

@mixin darktheme {
    /* All dark theme overrides */
}
html.theme-dark { @include darktheme; }
@media (prefers-color-scheme: dark) {
    html:not(.theme-light) { @include darktheme; }
}

But it makes the resulting css longer than necessary. And maintaining the dark mode overrides when I change anything in the css is harder than it should be. Surely there must be a better way?

Yes, there is. Css variables has been around since 2017, and by now they are supported by pretty much all browsers except discontinued MSIE and Opera Mini. So instead of trying to find all the styles I need to override to make a dark theme, I can move all colors to css variables and only override the variable declarations for the dark theme.

Like this:

html {
  --col-f: black;
  --col-b: #fdfbf9; /* paper */
  --col-fh: #3a0d46; /* hightligh foreground, dark purple */
  /* ... some more colors */
}
@mixin darktheme {
    --col-f: #fdfbf9; /* paper */
    --col-b:  #3a0d46; /* dark purple */
    --col-fh: wheat;
    /* ... some more colors */
}

html.theme-dark { @include darktheme; }
@media (prefers-color-scheme: dark) {
    html:not(.theme-light) { @include darktheme; }
}

body {
  color: var(--col-f);
  background: var(--col-b);
}
/* ... and all the rest of the styling */

That’s all for the css. What remains is the javascript for the button:

let b = document.documentElement;
let t = localStorage.getItem('theme');
if (t) {
    b.classList.add('theme-' + t)
}

function init() {
    let h = b.querySelector('header');
    if (b.lang == 'sv') {
        theme = 'tema'
    } else {
        theme = 'theme'
    }
    h.insertAdjacentHTML(
        'beforeend',
        `<p><button class="themeswitch">${theme}</button></p>`
    );
    h.querySelector('button.themeswitch').addEventListener(
        'click',
        function(e) {
            let c = b.classList;
            let l = localStorage;
            if (c.replace('theme-dark', 'theme-light')) {
                l.setItem('theme', 'light')
            } else if (c.replace('theme-light', 'theme-default')) {
                l.removeItem('theme')
            } else {
                c.remove('theme-default');
                c.add('theme-dark');
                l.setItem('theme', 'dark')
            }
            e.target.blur();
        }
    );
}

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
} else {
    init();
}

This has two pieces; a small one that is executed as soon as possible, and a larger (the init function) that is executed after the full DOM for the web page is loaded.

The immediate piece checks local storage for a field name theme, and if there is one it sets the relevant theme- class on the root element (<html>).

The init function creates a <button> (with the text theme or tema depending on the language) and sets an event listener for it. The listener cycles through the theme-dark, theme-light and theme-default classes for the root element, and saves the current value to local storage (for when the user goes to another page on the site, or goes away and comes back later).

The classList.replace function was really usefull here. If the classList contains the first token, it replaces it with the second and returns true, otherwise it returns false, keeping the check and the replace atomic, reducing the risk of bugs. In the third case, i don’t use that replace function, since not having any recognized theme- class is considered equivalent to having a theme-default class.

So, what do you think? Is there any obvious better way to do this that I missed? Do the themes look reasonably well on your device? Do you prefer the dark or the light theme? Comments are welcome!

Comments

Write a comment

Basic markdown is accepted.

Your name (or pseudonym).

Not published, except as gravatar.

Your presentation / homepage (if any).