DEV Community

Cover image for How to Build a Secret Dark Mode Toggle for Your Blog
Radek Pazdera
Radek Pazdera

Posted on • Originally published at radek.io

How to Build a Secret Dark Mode Toggle for Your Blog

When I started redesigning my blog a few weeks ago, I decided to put an easter egg in it. The original idea was to build a hidden game — like the T-Rex runner in Chrome. Pretty soon, it became clear that it could easily grow into a larger project then the blog itself. I couldn't justify that for an easter egg. I had to come up with something simpler.

One night, I was working late, migrating my old posts over. I forgot to dial the brightness on my screen down, which made the black text on white background particularly harsh on the eyes.

'Having dark mode would be great,' I thought. 'And what about secret dark mode?'

Now, that was an idea.

Preview

What You'll Learn

In this tutorial, I'll explain the steps I took to build this Easter egg. Feel free to build it exactly as I did it or mix and match different parts.

You'll learn how to

To avoid having to import a component framework into an otherwise static site, I did everything in vanilla HTML, CSS and JS.

Implementing dark mode

There are multiple ways to do this. I went down the custom properties route. The browser support is pretty good these days, but beware if you need to support older browsers.

We'll define custom properties for any colours that will need to change when switching between the light and dark themes. These should be accessible from anywhere in the document, so we'll put them under the :root pseudo-class.

:root {
  --background-color: #f6f6f6;
  --font-color: #222;
  --font-lighter-color: #444;
}
Enter fullscreen mode Exit fullscreen mode

This will be the default theme (light in this case). You can reference these colours using the var(--custom-prop) syntax in your stylesheets. Now, let's define the dark theme.

:root.dark {
  --background-color: #222;
  --font-color: #f6f6f6;
  --font-lighter-color: #ccc;
}
Enter fullscreen mode Exit fullscreen mode

These properties will override the original ones when we add the dark class to our root element (the <html> tag). Try doing that manually to see whether the theme changes.

<html class="dark">
  <head>...</head>
  <body>...</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Detecting OS-level dark mode setting

Most operating systems come with a setting that allows users to switch between the light and dark versions of the system UI. Some phones even change it automatically based on the time of day or available ambient light.

Fortunately for web devs, there's a media query to detect just that. We'll use it to show the dark mode by default for users with their system UI set to dark.

@media (prefers-color-scheme: dark) {
  :root {
    --background-color: #222;
    --font-color: #f6f6f6;
    --font-lighter-color: #ccc;
  }

  :root.light {
    --background-color: #f6f6f6;
    --font-color: #222;
    --font-lighter-color: #444;
  }
}
Enter fullscreen mode Exit fullscreen mode

We'll also define a new class called light that we'll use to override the defaults later.

When a user comes to our site, they'll see a theme based on their OS setting. But they can't change it yet. For that, we'll need to build a toggle.

Toggle Switch

Toggle hidden behind the logo

To build a simple toggle, we'll use the old label-and-invisible-checkbox trick. Although it won't be visible, the checkbox will store the state of our toggle. Using a clever combination of CSS selectors, we'll be able to control the toggle's position without adding any JS.

Here's the HTML:

<label class="toggle-switch" for="toggle-input">
    <input id="toggle-input" type="checkbox">
    <div class="toggle-switch__control"></div>
</label>
Enter fullscreen mode Exit fullscreen mode

When paired with a checkbox, clicking a label is the same as clicking the checkbox itself. This will allow us to change its state despite it being hidden.

Here's the CSS:

.toggle-switch {
    display: block;
}

#toggle-input {
    display: none;
}

.toggle-switch__control {
    width: 40px;
    height: 14px;
    border-radius: 7px;
    position: relative;

    background: #999;

    cursor: pointer;

    margin: 50px auto;
}

.toggle-switch__control::after {
    content: '';
    display: block;

    width: 20px;
    height: 20px;
    border-radius: 10px;

    position: absolute;
    left: -1px;
    top: -3px;

    background: var(--background-color);

    transition: left 0.25s;
}

#toggle-input:checked + .toggle-switch__control::after {
    left: 21px;
}
Enter fullscreen mode Exit fullscreen mode

The toggle-switch__control div makes up the background track of the switch. The knob on top is an ::after pseudo-element positioned above. We combine the :checked and + CSS selectors to change its position based on the state of the checkbox input. That way, we can avoid using any JS to animate the button.

I also placed an icon next to the toggle that shows which theme is on. See the CodePen at the end of the post for more details.

Switching Themes

First, we'll define a function called setTheme to switch between the light and dark themes.

function setTheme(theme, persist = false) {
    const on = theme;
    const off = theme === 'light' ? 'dark' : 'light'

    const htmlEl = document.documentElement;
    htmlEl.classList.add(on);
    htmlEl.classList.remove(off);

    if (persist) {
        localStorage.setItem('preferred-theme', theme);
    }
}
Enter fullscreen mode Exit fullscreen mode

The function adds the appropriate class to the document root based on the theme argument. If persist is set, it'll store the setting in localStorage.

Now, we need to hook setTheme() up to the toggle. We'll add a listener for the click event on the hidden checkbox.

const toggle = document.getElementById('toggle-input');
const lightIcon = document.getElementById('light-icon');
const darkIcon = document.getElementById('dark-icon');

function updateUI(theme) {
    toggle.checked = theme === 'light';

    if (theme === 'light') {
        lightIcon.classList.add('active');
        darkIcon.classList.remove('active');
    } else {
        darkIcon.classList.add('active');
        lightIcon.classList.remove('active');
    }
}

toggle.addEventListener('click', () => {
    const theme = toggle.checked ? 'light' : 'dark';
    setTheme(theme, true);
    updateUI(theme);
});
Enter fullscreen mode Exit fullscreen mode

Finally, we'll need to call setTheme() and updateUI() to set the initial theme based on the user's settings when the page loads up.

const osPreference = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const preferredTheme = localStorage.getItem('preferred-theme') || osPreference;

setTheme(preferredTheme, false);
updateUI(preferredTheme);
Enter fullscreen mode Exit fullscreen mode

The localStorage value takes precedence over the system-wide setting detected via media query. At this point, we set persist to false. We just want to apply the theme without saving the setting. Calling updateUI() will ensure that the toggle is in the right position.

This is it for the dark mode support.

Hiding the Toggle

First, we'll centre the toggle inside a container and position another one on top using position: absolute. Here's the HTML:

<div class="site-logo">
    <div class="site-logo__toggle-container">
        <img src="https://radek.io/assets/ext/light-icon.svg" id="light-icon">
        <img src="https://radek.io/assets/ext/dark-icon.svg" id="dark-icon">
        <label class="toggle-switch" for="toggle-input">
            <input id="toggle-input" type="checkbox">
            <div class="toggle-switch__control"></div>
        </label>
    </div>
    <div class="site-logo__logo">
        WHOA!
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

In the CSS, we'll position .site-logo__toggle-container and site-logo__logo absolutely. The toggle container will be slightly smaller and slightly offset (1px) relative to the logo to avoid rendering artefacts around the edges. The --open modifier will describe the position of the logo when it's open.

.site-logo {
    width: 125px;
    height: 125px;

    position: relative;
    margin: 40px auto;
}

.site-logo__toggle-container,
.site-logo__logo {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;

    position: absolute;
    border-radius: 50%;
}

.site-logo__toggle-container {
    width: calc(100% - 2px);
    height: calc(100% - 2px);

    top: 1px;
    left: 1px;

    background: var(--font-color);
}

.site-logo__logo {
    background: #ff5857;

    color: white;
    font-weight: bold;

    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

    width: 100%;
    height: 100%;
    border-radius: 50%;

    cursor: pointer;
    transition: all 0.25s;
    left: 0;
}

.site-logo__logo:hover {
    transform: scale(1.03);
}

.site-logo__logo--open {
    left: 85%;
    transform: scale(1.03);
}
Enter fullscreen mode Exit fullscreen mode

Now, let's give the user the ability to discover the toggle. Because we want the logo to close when the user clicks anywhere else on the page, our setup will be slightly more complicated. We'll have to attach a listener to window to check whenever the logo should auto-close.

const logo = document.querySelector('.site-logo__logo');
const container = document.querySelector('.site-logo__toggle-container');

function isLogoOpen() {
  return logo.classList.contains('site-logo__logo--open');
}

function autoClose(e) {
  if (isLogoOpen()) {
    const path = e.composedPath();

    /* Close the user clicks outside of the toggle/logo */
    if (path.indexOf(container) < 0 && path.indexOf(logo) < 0) {
      closeLogo();
      window.removeEventListener('click', autoClose);
    }
  }
}

function openLogo() {
  logo.classList.add('site-logo__logo--open');

  /* Start listening for clicks on the whole page */
  window.addEventListener('click', autoClose);
}

function closeLogo() {
  logo.classList.remove('site-logo__logo--open');

  /* Remove the global listener */
  window.removeEventListener('click', autoClose);
}

logo.addEventListener('click', () => isLogoOpen() ? closeLogo() : openLogo());
Enter fullscreen mode Exit fullscreen mode

The End

That's everything you need to hide a secret dark mode toggle or another Easter egg on your site. Feel free to use it as it is or experiment and turn it into something completely different!

Here's a CodePen with the full working implementation.

Thanks for reading, and let me know if you have any questions!


Radek Pazdera is a software engineer, writer and founder of Writing Analytics – an editor and writing tracker designed to help you create a sustainable writing routine.

Top comments (1)

Collapse
 
pazdera profile image
Radek Pazdera

Thank you! Let me know how it goes :).