Home
Head's Up: I'm in the middle of upgrading my site. Most things are in place, but there are something missing and/or broken including image alt text. Please bear with me while I'm getting things fixed.

A Light/Dark Mode Switcher With System Preferences

There's some weird attempts at accessibility in here right now. I'm still learning what I'm doing with that so consider this a work in progress for now.

Introduction

I'm building out an example site for my Neopoligen 1 website builder. It'll act as the default "getting started" site. I'm putting in several things to make it easier to get up and running for folks who don't have experience with websites yet. One of those things is a light/dark mode switcher. This page is where I'm building and testing that functionality.

The Code

Here's what I've got so far.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur venenatis, sem at laoreet facilisis, sapien nisi tincidunt purus, rhoncus lacinia lectus enim sit amet nibh. Nullam enim quam, ultricies et ipsum ut, porttitor laoreet turpis. Quisque eu massa.

JavaScript

customElements.define('example-color-switcher', 
  class ExampleColorSwitcher extends HTMLElement {
    constructor() {
      super()
      this.attachShadow({ mode: 'open' })
      this.loadConfig()
      this.addStyles()
      this.addWrapper()
      this.addButtons()
      this.addListeners()
      this.setInitialMode()
    }

    addButtons() {
      for (let mode in this.config.modes) {
        const button = this.config.modes[mode]
        const btn =  this.ownerDocument.createElement('button')
        btn.dataset.mode = mode
        btn.setAttribute('role', 'exampleMode')
        btn.addEventListener('click', (event) => {
          this.handleClick.call(this, event)
        }) 
        if (button.mode !== 'auto') {
          btn.innerHTML = `${button.text} ${button.token}`
        }
        this.wrapper.appendChild(btn)
      }
    }

    addListeners() {
      window.matchMedia('(prefers-color-scheme: dark)')
        .addEventListener('change', () => {
          this.updateAutoDisplay.call(this)
        })
    }

    addStyles() {
      const styles =  this.ownerDocument.createElement('style')
      styles.innerHTML = `
[role="exampleMode"] {
  color: currentColor;
  background: none;
  border: none;
  cursor: pointer;
  font: inherit;
  outline: none;
  filter: brightness(60%);
  margin: 0;
  padding: 0;
}

[role="exampleMode"][aria-selected="true"] {
  border-bottom: 2px solid currentColor;
  filter: brightness(100%);
}

.switcher-wrapper {
  margin: 0;
  display: flex;
  flex-wrap: warp;
  gap: 1.4rem;
}
`
      this.shadowRoot.appendChild(styles)
    }

    addWrapper() {
      this.wrapper = this.ownerDocument.createElement('div')
      this.wrapper.classList.add('switcher-wrapper')
      this.shadowRoot.appendChild(this.wrapper)
    }

    handleClick(event) {
      this.setMode(event.target.dataset.mode)
    }

    loadConfig() {
      this.config = {
        modes: {
          light: { text: "Light", token: ""},
          dark: { text: "Dark", token: "" },
          auto: { text: "", token: "" },
        }
      }
    }

    setInitialMode() {
      this.updateAutoDisplay.call(this)
      const mode = localStorage.getItem('colorMode')
      if (mode) {
        this.setMode(mode)
      } else {
        this.setMode('auto')
      }
    }

    setMode(mode) {
      localStorage.setItem('colorMode', mode)
      if (mode === `auto`) {
        document.body.classList.remove('light')
        document.body.classList.remove('dark')
      } else {
        const removeMode = mode === 'light' ? 'dark' : 'light'
        document.body.classList.add(mode)
        document.body.classList.remove(removeMode)
      }
      const buttons = this.shadowRoot.querySelectorAll(`[role="exampleMode"]`)
      buttons.forEach((button) => {
        if (button.dataset.mode === mode) {
          button.setAttribute('aria-selected', true)
        } else {
          button.setAttribute('aria-selected', false)
        }
      })
    }

    updateAutoDisplay() {
      const els = this.shadowRoot.querySelectorAll('[role="exampleMode"][data-mode="auto"]')
      els.forEach((el) => {
        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
          el.innerHTML = `Auto (${this.config.modes.dark.token})`
        } else {
          el.innerHTML = `Auto (${this.config.modes.light.token})`
        }
      })
    }
  }
)

CSS

body {
  --example-color: black;
  --example-bg-color: #ccc;
  --example-color-selected: black;
  --example-color-not-selected: #555;
}

body.dark {
  --example-color: #ccc;
  --example-bg-color: black;
  --example-color-selected: #ccc;
  --example-color-not-selected: #888;
}

@media (prefers-color-scheme: dark) { 
  body {
    --example-color: #ccc;
    --example-bg-color: black;
    --example-color-selected: #ccc;
    --example-color-not-selected: #888;
  }
  body.light {
    --example-color: black;
    --example-bg-color: #ccc;
    --example-color-selected: black;
    --example-color-not-selected: #555;
  }
}

.example-wrapper {
  color: var(--example-color);
  background-color: var(--example-bg-color);
}

example-color-switcher {
  display: inline-block;
  margin-block: 0.8rem;
}

Usage

I keep the javascript in a file called [TODO: Code shorthand span ] with the rest of my web components. That gets loaded on the page with :

html
<script src="/path/to/components.js" type="module"></script>

- Another goal is to be able to put this element on the page multiple times and have them all work and stay in sync. I haven't done much work with web components so I'm not sure what the possible approaches are, but I'm sure it's possible. I'll look into that at some point

- The Auto feature picks up system settings if the browser has access to them. If you go into your preferences and change them the page will update as well

- This approach duplicates each set of styles to line things up for light and dark mode. There are ways to do this without the duplication, but I'm not worried about it. Lots of other things across my site to work on before trying for that micro - optimization

Footnotes And References