Automatic OS theme detection with manual toggle

Jun 23, 2021 5 min read

Automatic OS theme detection with manual toggle

If you don't want your site to be glaring white when everything else on your system is dark, you've come to the right place! As you might have noticed I have automatic theme detection on my site and it's very easy to do. Currently, one way to do it is by using prefers-color-scheme. Let's begin.

Javascript

Let's start by selecting the html tag where theme class will be applied. If the theme is set in localStorage, it will set that theme. Otherwise, it will check prefers-color-scheme for dark match and set the default theme based on that.

const html = document.querySelector("html");
const theme = window.localStorage.getItem('theme');

if ("theme" in localStorage) {
    html.setAttribute('class', theme);
} else {
    window.matchMedia('(prefers-color-scheme: dark)').matches ? setTheme('dark') : setTheme('light');
}

Now, let's create a function. When used, it will toggle the class for the html tag and set the value in the localStorage.

function setTheme(theme) {
    html.setAttribute('class',theme);
    window.localStorage.setItem('theme',theme);
}

Let's use event listener for prefers-color-scheme media feature we mentioned earlier. This is the automatic part that changes theme based on the OS settings.

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
    e.matches ? setTheme('dark') : setTheme('light');
});

This will effectively change the theme on fly as you toggle the OS setting. If you don't want this behaviour you can use this alternative to check whenever preferred theme is already set in the storage.

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
    if ("theme" in localStorage) {
        html.setAttribute('class', theme);
    } else {
        e.matches ? setTheme('dark') : setTheme('light');
    }
});

We have to add event listener to the document itself, because the button hasn't loaded yet. On button click/tap we just check the html class attribute and switch the theme accordingly.

function handler(evt) {
    evt.preventDefault();
    const currentTheme = html.getAttribute('class');
    const switchTheme = currentTheme === 'light' ? 'dark' : 'light';
    setTheme(switchTheme);
}

document.addEventListener('DOMContentLoaded', function() {
    const button = document.getElementById("theme-toggle");
    button.addEventListener('touchstart', handler);
    button.addEventListener('click', handler);
});

HTML

Let's add button for toggling the theme. It's not the greatest sun/moon transition ever. It's using faux mask overlay to achieve the effect I wanted. Haven't found a better solution yet.

<button class="theme-toggle" id="theme-toggle">
    <svg width="100%" height="100%" viewBox="0 0 14 14">
        <circle class="main" cx="7" cy="7" r="5" />
        <path class="shadow" d="M13,1l0,5.301c-0.724,1.027 -1.92,1.699 -3.272,1.699c-2.207,0 -4,-1.792 -4,-4c0,-1.194 0.525,-2.267 1.356,-3l5.916,0Z" />
        <path class="overlay" d="M15,-1l-16,0l0,16l16,0l0,-16Zm-8,2.5c3.036,0 5.5,2.464 5.5,5.5c0,3.036 -2.464,5.5 -5.5,5.5c-3.036,0 -5.5,-2.464 -5.5,-5.5c0,-3.036 2.464,-5.5 5.5,-5.5Z" />
        <g class="rays">
            <path id="ray-1" d="M7,2.432l0,-0.932" />
            <path id="ray-2" d="M10.209,3.791l0.653,-0.653" />
            <path id="ray-3" d="M11.568,7l0.932,-0" />
            <path id="ray-4" d="M10.209,10.209l0.653,0.653" />
            <path id="ray-5" d="M7,11.568l0,0.932" />
            <path id="ray-6" d="M3.791,10.209l-0.653,0.653" />
            <path id="ray-7" d="M2.432,7l-0.932,0" />
            <path id="ray-8" d="M3.791,3.791l-0.653,-0.653" />
        </g>
    </svg>
</button>

SCSS

This is scaled down version of my SCSS vars. Essentially just define your main colors here and you're good to go. Keep --toggle unchanged, because they will blend nicely to their corresponding theme via screen and multiply blend mode.

:root {
    --toggle: #FFFFFF;
    --background: #F8F8F8;
    --text: #282435;
    &.dark {
        --toggle: #000000;
        --background: #1A191F;
        --text: #DDDBE4;
    }
}
$fast: .3s;
$easing: cubic-bezier(0.250, 0.460, 0.450, 0.940);

In this part we define the look, transition and animation of our toggle button. In order to fix the faux mask overlay we have to apply mix-blend-mode to the button itself.

.theme-toggle {
    background-color: transparent;
    border: 0;
    padding: 0;
    display: block;
    width: 24px;
    height: 24px;
    cursor: pointer;
    outline: none;
    overflow: hidden;
    position: relative;
    mix-blend-mode: multiply;
    svg { 
        position: absolute;
        top: 0;
        right: 0;
    }
    .overlay { 
        fill: var(--toggle);
    }
    .main,.shadow {
        stroke: var(--text);
        stroke-width: 1px;
        transition: transform $fast $easing;
    }
    .main {
        transform: scale(0.5);
        stroke-width: 1.6px;
        transform-origin: center center;
        fill: transparent;
    }
    .shadow { 
        fill: var(--toggle);
        transform-origin: top right;
        transform: translatex(10px) translatey(-10px);
    }
    .rays path { 
        stroke: var(--text);
        stroke-width: 1px;
        stroke-dashoffset: 0;
        stroke-dasharray: 0;
        stroke-linecap: round;
    }
    @for $i from 1 to 9 {
        .rays path:nth-child(#{$i}) { transition-delay: $i * 0.03s; }
    }
}

.dark {
    .theme-toggle {
        mix-blend-mode: screen;
        .main {
            transform: scale(1);
            stroke-width: 1px;
        }
        .shadow {
            transform: translatex(0) translatey(0);
        }
        .rays path { 
            stroke-width: 0px;
            stroke-dashoffset: 2;
            stroke-dasharray: 2;
            transition: all $fast $easing;
        }
    }
}

Final notes

Generally, this should work in all modern browsers. But even if it doesn't, it loads the default light theme. And user still has an option to toggle it manually. Message me if you think you have better solution or code :)

Important: In order to prevent colors flashing every time you visit some page, you need to include javascript in the header.

Last modified on Jul 16, 2021