Light/Dark Mode

How has this been done?

Here are the important bits - this is the bare bones, the styling is up to you:

HTML

// create the toggle buttons with onclick function calls
<button id="lightBtn" onclick="switchLight()">light</button>
<button id="darkBtn" onclick="switchDark()">dark</button>

CSS

// set your page to automatically respond to OS settings on load
// seriously, go play with the color-scheme property, it's ace!
html {
  color-scheme: light dark;
}

JavaScript

// first, a useful variable to neaten things up:
const htmlRoot = document.querySelector("html");

// set color-scheme to dark and switch to light button
function switchDark() {
  htmlRoot.style.colorScheme = "dark";
}
// set color-scheme to light and switch to dark button
function switchLight() {
  htmlRoot.style.colorScheme = "light";
}

Want to do more styling?

Easy hack

Use transparency as I've done above, and you can style on top of dark or light colours without having to make your properties different, for light and dark mode. This can lead to muted outcomes, however.

Doing it "properly"

Specific styles for

Click toggle in header for

There are many ways to skin this particular cat. I'm basing this code on the idea that once people start toggling the dark/light option on the page, that they no longer want the Operating System to control it, so flipping the option in the OS then does nothing. However, I am also storing nothing, so a refresh of the page sets everything back to the OS setting as default.

The logic gets a little more involved, now...

You'll need to put what you consider your non-default styles or custom properties into a new class - for me that's .dark-mode as I consider light to be the default.

More JavaScript!

// the same variable as before:
const htmlRoot = document.querySelector("html");

// check for color-scheme on html element, if not set, then...
if (!htmlRoot.style.colorScheme) {
  // ..if user OS is in dark mode...
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    // ..add dark-mode class to html element
    htmlRoot.classList.add("dark-mode");
  }
}

// now we can to check for live changes, by adding an
// event listener in case our user flips the OS mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
  // only run if the html element color-scheme is not set
  if (!htmlRoot.style.colorScheme) {
    // add/remove dark mode to match OS setting
    if(event.matches) {
      htmlRoot.classList.add("dark-mode");
    } else {
      htmlRoot.classList.remove("dark-mode");
    }
  }
});

// finally we add another line to each of our functions,
// switchDark and switchLight, to include the mode changes
function switchDark() {
  htmlRoot.style.colorScheme = "dark";
  htmlRoot.classList.add("dark-mode");
}
function switchLight() {
  htmlRoot.style.colorScheme = "light";
  htmlRoot.classList.remove("dark-mode");
}

CSS example

So whichever class you are now toggling into the html element with this new JavaScript, you can now use for styling for that mode.

Below is the CSS for what you saw above, the purple and yellow text/backgrounds. This is just an example, not ideal CSS - but do note that the .dark-mode selectors come after the light mode defaults!

.mode-styling {
  color: rebeccapurple;
  background-color: gold;
  text-align: center;
  width: fit-content;
  margin: 0.25em auto;
  padding: 0.5em 0.75em;
  border-radius: 0.5em;
}

div.mode-styling { font-size: 2rem; }

.mode-styling::after { content: " dark mode" }
div.mode-styling::after { content: " light mode" }

.dark-mode .mode-styling {
  background-color: rebeccapurple;
  color: gold;
}

.dark-mode .mode-styling::after { content: " light mode" }
.dark-mode div.mode-styling::after { content: " dark mode" }

Make it Accessible

With Styling

My first version of this page had the toggle button of the mode you were on disappear with display: none; - so when the page was dark, the dark button was no longer there. This was confusing and not great for screen readers, so now they're both there all the time, and button for the active mode looks depressed into the page.

This is actually relatively simple to do, given that you can call up the current background and text colours with Canvas and CanvasText, which flip along with the color-scheme setting:

CSS

// the light button will have a dark inset shadow in light mode,
// and a light inset shadow when the page is in dark mode 
.mode-switcher #lightBtn {
    box-shadow: 2px 2px 5px -2px CanvasText inset;
}
// the dark button has the opposite :)
.mode-switcher #darkBtn {
    box-shadow: 2px 2px 5px -2px Canvas inset;
}

With ARIA

Even better, you can announce to screen readers and other assistive technologies when a button has been pressed, making it much clearer what is going on.

We'll take the Javascript we've got so far and add some extra lines to write ARIA attributes into the button elements.

Final Accessible JavaScript

// a couple more useful variables    
const htmlRoot = document.querySelector("html");
const lightButton = document.querySelector("#lightBtn");
const darkButton = document.querySelector("#darkBtn");

if (!htmlRoot.style.colorScheme) {
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
        htmlRoot.classList.add("dark-mode");
        darkButton.setAttribute("aria-pressed", "true");
        lightButton.setAttribute("aria-pressed", "false");
    } else {
        darkButton.setAttribute("aria-pressed", "false");
        lightButton.setAttribute("aria-pressed", "true");
    }
}

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
    if (!htmlRoot.style.colorScheme) {
        if(event.matches) {
            htmlRoot.classList.add("dark-mode");
            darkButton.setAttribute("aria-pressed", "true");
            lightButton.setAttribute("aria-pressed", "false");
        } else {
            htmlRoot.classList.remove("dark-mode");
            darkButton.setAttribute("aria-pressed", "false");
            lightButton.setAttribute("aria-pressed", "true");
        }
    }
});

function switchDark() {
    htmlRoot.style.colorScheme = "dark";
    htmlRoot.classList.add("dark-mode");
    darkButton.setAttribute("aria-pressed", "true");
    lightButton.setAttribute("aria-pressed", "false");
}
function switchLight() {
    htmlRoot.style.colorScheme = "light";
    htmlRoot.classList.remove("dark-mode");
    darkButton.setAttribute("aria-pressed", "false");
    lightButton.setAttribute("aria-pressed", "true");
}

Limitations

As you can see, this is just one single page. Not even an SPA, just one page!

None of this code is storing preferences anywhere, so a refresh, or navigating away and back to this page, will reset the color-scheme mode back to one that matches the operating system of the user.

Currently, I feel that's OK! But this page may get expanded to elaborate on what the options are, in the case of a larger website with multiple pages :)