Come to the light side

Implementing a dark/light mode switch

30 April 2022

This blog had dark mode as the default color scheme, but I know how strong the opinions of developers are about dark and light mode. That's why I wanted to implement a dark/light mode switch.

Setting up dark mode in Tailwind

The design of this blog was done with Tailwind. By default, this blog only has a dark mode. Luckily, adding a toggle is extremely easy with Tailwind. The first thing I did, was configuring Tailwind to use the class strategy instead of the media strategy. This means that the dark:{class} classes are now being applied when html got a class called dark. This becomes clear with an example from the documentation.

<!-- Dark mode not enabled -->
<html>
<body>
  <!-- Will be white -->
  <div class="bg-white dark:bg-black">
    <!-- ... -->
  </div>
</body>
</html>

<!-- Dark mode enabled -->
<html class="dark">
<body>
  <!-- Will be black -->
  <div class="bg-white dark:bg-black">
    <!-- ... -->
  </div>
</body>
</html>

Setting this up is extremely easy. Just edit your tailwind.config.js:

module.exports = {
    darkMode: 'class',
    // ...
}

Adding the toggle

The next step is to add the toggle to website. If the visitor is in dark mode, I want the icon to be a sun (to clarify that clicking it will switch the website to light mode). If the use is currently in light mode, I want the icon to be a moon.

I simply added both icons to my heading:

<div class="py-4" id="light-icon">
    <a href="#" aria-label="Enable light mode" onclick="toggleDarkMode()">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
             class="h-8 ml-4 md:ml-8 fill-gray-600 hover:fill-gray-800 dark:fill-gray-300 dark:hover:fill-white">
            <path
                d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/>
        </svg>
    </a>
</div>

<div class="py-4" id="dark-icon">
    <a href="#" aria-label="Enable dark mode" onclick="toggleDarkMode()">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
             class="h-8 ml-4 md:ml-8 fill-gray-600 hover:fill-gray-800 dark:fill-gray-300 dark:hover:fill-white">
            <path
                d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/>
        </svg>
    </a>
</div>

This shows both icons in the header. Of course, we only always want to show one of the two icons. That can be easily done in JavaScript. When someone clicks the icon, I want toggleDarkMode() to hide one of the icons and unhide the other one.

function toggleDarkMode() {
    if (document.documentElement.classList.contains('dark')) {
        setLightMode();
    } else {
        setDarkMode();
    }
}

Now, we "just" need to implement those functions. There are a few things that we need when setting dark mode:

  1. Set dark as a class on html.
  2. Hide the dark-icon.
  3. Unhide the light-icon.

Thus, the implementation looks as follows:

function setDarkMode() {
    document.documentElement.classList.add('dark');
    document.getElementById('light-icon').classList.remove('hidden');
    document.getElementById('dark-icon').classList.add('hidden');
}

Of course, the implementation for setLightMode() is simply the reverse.

Designing for dark and light mode

After setting this, nothing appears to happen when you click one of the icons in the header. Of course, we still need to implement some design rules in order to actually have a dark and a light mode. Designing for both light and dark mode, is extremely easy in Tailwind. The first thing I changed, was the background color:

<body class="bg-gradient-to-br from-gray-200 to-white dark:from-gray-800 dark:to-black min-h-full">
    <!-- Contents -->
</body>

Using this technique, I quickly changed all colors to have both a light and dark mode. Now, when you click one of the icons, the color scheme switches.

Adding cookies for persistence

If you switch the color scheme and reload, or go to another page, you'll see that the color scheme changes back. This is why we need to implement cookies. Luckily, this is just as easy in JavaScript.

function setDarkMode(set_cookie = false) {
    document.documentElement.classList.add('dark');
    document.getElementById('light-icon').classList.remove('hidden');
    document.getElementById('dark-icon').classList.add('hidden');

    if (set_cookie) {
        document.cookie = 'dark-mode=true; path=/';
    }
}

I have already added an argument to the function to be able to ignore the cookies. This will prove to be useful later on. For now, we'll just pass true when using the function, so don't forget to update the implementation in toggleDarkMode(). Of course, you'll also have to do the same for setLightMode().

Now that we have a way to set the cookies, we'll need a way to check for them when the page loads.

if (document.cookie.indexOf('dark-mode=true') > -1) {
    setDarkMode();
} else if (document.cookie.indexOf('dark-mode=false') > -1) {
    setLightMode();
}

This code effectively checks if the string is somewhere to be found in your cookie. If that's the case, it will set a dark or a light mode. If no cookie has been set, it won't do anything. That's why you'll have to hide one of your icons by default. If you're chosing to set dark mode as the default value, you'll also have to add dark as a class to your html of course.

Implementing the system color scheme

Of course, users have their own preferences. Some have set their system settings to dark mode, others have set it to light mode. These preferences can be gotten in JavaScript with matchMedia.

if (matchMedia('(prefers-color-scheme: dark)').matches) {
    setDarkMode();
} else {
    setLightMode();
}

Combining this with the cookies, we get the following code:

if (document.cookie.indexOf('dark-mode=true') > -1 ||
    window.matchMedia('(prefers-color-scheme: dark)').matches) {
    setDarkMode();
} else if (document.cookie.indexOf('dark-mode=false') > -1 ||
    window.matchMedia('(prefers-color-scheme: light)').matches) {
    setLightMode();
}

Now, the system preference will be used when the page loads for the first time. If the user presses the switch, the cookie will be set to the opposite value.

Changing the code blocks

On my website, there's one thing that can't be with Tailwind on my website: the code blocks. These are generated with spatie/shiki-php. The result is a <pre> element. The color scheme can be set before compilation, but this website is generated to static HTML, so it's not possible to set the color scheme after compilation. Therefore, I'm not able to change the color scheme of the code blocks.

That's why I'm generating every code block twice. The first time, I'm using the default color scheme. The second time, I'm using the dark color scheme. Then, based on the selected color scheme, only one of the two code blocks will be visible. The result is the following function:

function setDarkMode(set_cookie = false) {
    document.documentElement.classList.add('dark');
    document.getElementById('light-icon').classList.remove('hidden');
    document.getElementById('dark-icon').classList.add('hidden');

    if (set_cookie) {
        document.cookie = 'dark-mode=true; path=/';
    }

    // Update the code snippets
    var codeBlocks = document.getElementsByClassName('light-code');
    for (var i = 0; i < codeBlocks.length; i++) {
        codeBlocks[i].classList.add('hidden');
    }

    var codeBlocks = document.getElementsByClassName('dark-code');
    for (var i = 0; i < codeBlocks.length; i++) {
        codeBlocks[i].classList.remove('hidden');
    }
}

And that's it! The code blocks are now updated to use the different color schemes.

The complete code

This post uses a lot of code snippets. This is the complete code as a result of everything we have done. If you're interested in how this works, just click the button in the top right corner!

// Initialize dark mode based on cookie
    if (document.cookie.indexOf('dark-mode=true') > -1 ||
        window.matchMedia('(prefers-color-scheme: dark)').matches) {
        setDarkMode();
    } else if (document.cookie.indexOf('dark-mode=false') > -1 ||
        window.matchMedia('(prefers-color-scheme: light)').matches) {
        setLightMode();
    }

    function toggleDarkMode() {
        if (document.documentElement.classList.contains('dark')) {
            setLightMode(true);
        } else {
            setDarkMode(true);
        }
    }

    function setDarkMode(set_cookie = false) {
        document.documentElement.classList.add('dark');
        document.getElementById('light-icon').classList.remove('hidden');
        document.getElementById('dark-icon').classList.add('hidden');

        if (set_cookie) {
            document.cookie = 'dark-mode=true; path=/';
        }

        // Update the code snippets
        var codeBlocks = document.getElementsByClassName('light-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.add('hidden');
        }

        var codeBlocks = document.getElementsByClassName('dark-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.remove('hidden');
        }
    }

    function setLightMode(set_cookie = false) {
        document.documentElement.classList.remove('dark');
        document.getElementById('light-icon').classList.add('hidden');
        document.getElementById('dark-icon').classList.remove('hidden');

        if (set_cookie) {
            document.cookie = 'dark-mode=false; path=/';
        }

        // Update the code snippets
        var codeBlocks = document.getElementsByClassName('light-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.remove('hidden');
        }

        var codeBlocks = document.getElementsByClassName('dark-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.add('hidden');
        }
    }

This blog had dark mode as the default color scheme, but I know how strong the opinions of developers are about dark and light mode. That's why I wanted to implement a dark/light mode switch.

Setting up dark mode in Tailwind

The design of this blog was done with Tailwind. By default, this blog only has a dark mode. Luckily, adding a toggle is extremely easy with Tailwind. The first thing I did, was configuring Tailwind to use the class strategy instead of the media strategy. This means that the dark:{class} classes are now being applied when html got a class called dark. This becomes clear with an example from the documentation.

<!-- Dark mode not enabled -->
<html>
<body>
  <!-- Will be white -->
  <div class="bg-white dark:bg-black">
    <!-- ... -->
  </div>
</body>
</html>

<!-- Dark mode enabled -->
<html class="dark">
<body>
  <!-- Will be black -->
  <div class="bg-white dark:bg-black">
    <!-- ... -->
  </div>
</body>
</html>

Setting this up is extremely easy. Just edit your tailwind.config.js:

module.exports = {
    darkMode: 'class',
    // ...
}

Adding the toggle

The next step is to add the toggle to website. If the visitor is in dark mode, I want the icon to be a sun (to clarify that clicking it will switch the website to light mode). If the use is currently in light mode, I want the icon to be a moon.

I simply added both icons to my heading:

<div class="py-4" id="light-icon">
    <a href="#" aria-label="Enable light mode" onclick="toggleDarkMode()">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
             class="h-8 ml-4 md:ml-8 fill-gray-600 hover:fill-gray-800 dark:fill-gray-300 dark:hover:fill-white">
            <path
                d="M256 159.1c-53.02 0-95.1 42.98-95.1 95.1S202.1 351.1 256 351.1s95.1-42.98 95.1-95.1S309 159.1 256 159.1zM509.3 347L446.1 255.1l63.15-91.01c6.332-9.125 1.104-21.74-9.826-23.72l-109-19.7l-19.7-109c-1.975-10.93-14.59-16.16-23.72-9.824L256 65.89L164.1 2.736c-9.125-6.332-21.74-1.107-23.72 9.824L121.6 121.6L12.56 141.3C1.633 143.2-3.596 155.9 2.736 164.1L65.89 256l-63.15 91.01c-6.332 9.125-1.105 21.74 9.824 23.72l109 19.7l19.7 109c1.975 10.93 14.59 16.16 23.72 9.824L256 446.1l91.01 63.15c9.127 6.334 21.75 1.107 23.72-9.822l19.7-109l109-19.7C510.4 368.8 515.6 356.1 509.3 347zM256 383.1c-70.69 0-127.1-57.31-127.1-127.1c0-70.69 57.31-127.1 127.1-127.1s127.1 57.3 127.1 127.1C383.1 326.7 326.7 383.1 256 383.1z"/>
        </svg>
    </a>
</div>

<div class="py-4" id="dark-icon">
    <a href="#" aria-label="Enable dark mode" onclick="toggleDarkMode()">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
             class="h-8 ml-4 md:ml-8 fill-gray-600 hover:fill-gray-800 dark:fill-gray-300 dark:hover:fill-white">
            <path
                d="M32 256c0-123.8 100.3-224 223.8-224c11.36 0 29.7 1.668 40.9 3.746c9.616 1.777 11.75 14.63 3.279 19.44C245 86.5 211.2 144.6 211.2 207.8c0 109.7 99.71 193 208.3 172.3c9.561-1.805 16.28 9.324 10.11 16.95C387.9 448.6 324.8 480 255.8 480C132.1 480 32 379.6 32 256z"/>
        </svg>
    </a>
</div>

This shows both icons in the header. Of course, we only always want to show one of the two icons. That can be easily done in JavaScript. When someone clicks the icon, I want toggleDarkMode() to hide one of the icons and unhide the other one.

function toggleDarkMode() {
    if (document.documentElement.classList.contains('dark')) {
        setLightMode();
    } else {
        setDarkMode();
    }
}

Now, we "just" need to implement those functions. There are a few things that we need when setting dark mode:

  1. Set dark as a class on html.
  2. Hide the dark-icon.
  3. Unhide the light-icon.

Thus, the implementation looks as follows:

function setDarkMode() {
    document.documentElement.classList.add('dark');
    document.getElementById('light-icon').classList.remove('hidden');
    document.getElementById('dark-icon').classList.add('hidden');
}

Of course, the implementation for setLightMode() is simply the reverse.

Designing for dark and light mode

After setting this, nothing appears to happen when you click one of the icons in the header. Of course, we still need to implement some design rules in order to actually have a dark and a light mode. Designing for both light and dark mode, is extremely easy in Tailwind. The first thing I changed, was the background color:

<body class="bg-gradient-to-br from-gray-200 to-white dark:from-gray-800 dark:to-black min-h-full">
    <!-- Contents -->
</body>

Using this technique, I quickly changed all colors to have both a light and dark mode. Now, when you click one of the icons, the color scheme switches.

Adding cookies for persistence

If you switch the color scheme and reload, or go to another page, you'll see that the color scheme changes back. This is why we need to implement cookies. Luckily, this is just as easy in JavaScript.

function setDarkMode(set_cookie = false) {
    document.documentElement.classList.add('dark');
    document.getElementById('light-icon').classList.remove('hidden');
    document.getElementById('dark-icon').classList.add('hidden');

    if (set_cookie) {
        document.cookie = 'dark-mode=true; path=/';
    }
}

I have already added an argument to the function to be able to ignore the cookies. This will prove to be useful later on. For now, we'll just pass true when using the function, so don't forget to update the implementation in toggleDarkMode(). Of course, you'll also have to do the same for setLightMode().

Now that we have a way to set the cookies, we'll need a way to check for them when the page loads.

if (document.cookie.indexOf('dark-mode=true') > -1) {
    setDarkMode();
} else if (document.cookie.indexOf('dark-mode=false') > -1) {
    setLightMode();
}

This code effectively checks if the string is somewhere to be found in your cookie. If that's the case, it will set a dark or a light mode. If no cookie has been set, it won't do anything. That's why you'll have to hide one of your icons by default. If you're chosing to set dark mode as the default value, you'll also have to add dark as a class to your html of course.

Implementing the system color scheme

Of course, users have their own preferences. Some have set their system settings to dark mode, others have set it to light mode. These preferences can be gotten in JavaScript with matchMedia.

if (matchMedia('(prefers-color-scheme: dark)').matches) {
    setDarkMode();
} else {
    setLightMode();
}

Combining this with the cookies, we get the following code:

if (document.cookie.indexOf('dark-mode=true') > -1 ||
    window.matchMedia('(prefers-color-scheme: dark)').matches) {
    setDarkMode();
} else if (document.cookie.indexOf('dark-mode=false') > -1 ||
    window.matchMedia('(prefers-color-scheme: light)').matches) {
    setLightMode();
}

Now, the system preference will be used when the page loads for the first time. If the user presses the switch, the cookie will be set to the opposite value.

Changing the code blocks

On my website, there's one thing that can't be with Tailwind on my website: the code blocks. These are generated with spatie/shiki-php. The result is a <pre> element. The color scheme can be set before compilation, but this website is generated to static HTML, so it's not possible to set the color scheme after compilation. Therefore, I'm not able to change the color scheme of the code blocks.

That's why I'm generating every code block twice. The first time, I'm using the default color scheme. The second time, I'm using the dark color scheme. Then, based on the selected color scheme, only one of the two code blocks will be visible. The result is the following function:

function setDarkMode(set_cookie = false) {
    document.documentElement.classList.add('dark');
    document.getElementById('light-icon').classList.remove('hidden');
    document.getElementById('dark-icon').classList.add('hidden');

    if (set_cookie) {
        document.cookie = 'dark-mode=true; path=/';
    }

    // Update the code snippets
    var codeBlocks = document.getElementsByClassName('light-code');
    for (var i = 0; i < codeBlocks.length; i++) {
        codeBlocks[i].classList.add('hidden');
    }

    var codeBlocks = document.getElementsByClassName('dark-code');
    for (var i = 0; i < codeBlocks.length; i++) {
        codeBlocks[i].classList.remove('hidden');
    }
}

And that's it! The code blocks are now updated to use the different color schemes.

The complete code

This post uses a lot of code snippets. This is the complete code as a result of everything we have done. If you're interested in how this works, just click the button in the top right corner!

// Initialize dark mode based on cookie
    if (document.cookie.indexOf('dark-mode=true') > -1 ||
        window.matchMedia('(prefers-color-scheme: dark)').matches) {
        setDarkMode();
    } else if (document.cookie.indexOf('dark-mode=false') > -1 ||
        window.matchMedia('(prefers-color-scheme: light)').matches) {
        setLightMode();
    }

    function toggleDarkMode() {
        if (document.documentElement.classList.contains('dark')) {
            setLightMode(true);
        } else {
            setDarkMode(true);
        }
    }

    function setDarkMode(set_cookie = false) {
        document.documentElement.classList.add('dark');
        document.getElementById('light-icon').classList.remove('hidden');
        document.getElementById('dark-icon').classList.add('hidden');

        if (set_cookie) {
            document.cookie = 'dark-mode=true; path=/';
        }

        // Update the code snippets
        var codeBlocks = document.getElementsByClassName('light-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.add('hidden');
        }

        var codeBlocks = document.getElementsByClassName('dark-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.remove('hidden');
        }
    }

    function setLightMode(set_cookie = false) {
        document.documentElement.classList.remove('dark');
        document.getElementById('light-icon').classList.add('hidden');
        document.getElementById('dark-icon').classList.remove('hidden');

        if (set_cookie) {
            document.cookie = 'dark-mode=false; path=/';
        }

        // Update the code snippets
        var codeBlocks = document.getElementsByClassName('light-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.remove('hidden');
        }

        var codeBlocks = document.getElementsByClassName('dark-code');
        for (var i = 0; i < codeBlocks.length; i++) {
            codeBlocks[i].classList.add('hidden');
        }
    }