Theme switch

A theme switch button for manually switching color-schemes whilst also respecting user preferences for light or dark mode viewing, also includes options for styling the button with Themalize icons and two pre-styled buttons. Disabled by default it requires $enable-manual-themes: set to true in the configuration.scss document.

Script (anchor)

The script theme.js [assets/js] needs to be loaded prior to the style sheets in the head of the HTML document (to negate FOUC when changing or refreshing pages).

theme.js

The script has been adapted from Adam Argyle's Building a theme switch component article on Google's web.dev to use the aria-pressed attribute to indicate button status following advice provided in an Adrian Roselli article on improving the accessibility of the same button and script as it was being used on a Google promotional website.

const storageKey = 'theme-preference'

const onClick = () => {
  theme.value = theme.value === 'false'
  ? 'true'
  : 'false'
  setPreference()
}

const getColorPreference = () => {
  if (localStorage.getItem(storageKey))
  return localStorage.getItem(storageKey)
  else
  return window.matchMedia('(prefers-color-scheme: dark)').matches
  ? 'true'
  : 'false'
}

const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}

const reflectPreference = () => {
  document.firstElementChild
  .setAttribute('data-prefers-dark', theme.value)

  document
  .querySelector('#themes')
  ?.setAttribute('aria-pressed', theme.value)
}

const theme = {
  value: getColorPreference(),
}

reflectPreference()

window.onload = () => {
  reflectPreference()
  document
  .querySelector('#themes')
  .addEventListener('click', onClick)
}

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
  theme.value = isDark ? 'true' : 'false'
  setPreference()
})

Button (anchor)

A button with the same attributes as the example below needs to be added to the page.

<button id="themes" aria-pressed="false">Dark mode</button>

The button is provided unstyled so can be designed using your own styles, or alternatively the styling options described below can be used and customized as required.

Theme switch button styling (anchor)

Designed for the pre-styled button options, or to work with custom button styles using the Themalize icons, the following method can also used with your own theme switch button styles if preferred.

With $enable-theme-switch: true; the following variables are compiled with the color-scheme variables in the _variables.scss document, the attributes are designed to work with the pre-styled buttons as described below.

:where(html) {
  color-scheme: light;
  /* light mode variables */
  --switch-ico: var(--switch-off);
}

:where([data-prefers-dark=true]) {
  color-scheme: dark;
  /* dark mode variables */
  --switch-ico: var(--switch-on);
}

Applied through the color-schemes the icons don't suffer from FOUC when changing or refreshing pages, so it's recommended if designing your own theme switch button to apply the styles using the same method.

Pre-styled buttons (anchor)

Two optional theme switch buttons use the variables above with the Themalize icons and have been written to work independently if the individual icons or utility styles are not enabled in the configuration.scss document.

$enable-switch-celestial: true;

$enable-switch-radio: true;

Dark offDark on

Both use the same attributes so the button doesn't have to be altered when changing styles. The .vis-hidden text utility is included with the typography styles by default.

<button id="themes" aria-pressed="false"><span class="vis-hidden">Dark mode</span></button>

The styles are provided in the _theme-switch.scss document in the [styles/components] directory.

_theme-switch.scss

Currently the styles still require $enable-forms-buttons: true; for the basic button styles but the icon styling will still work with fallback user-agent styles so can be customized as required if the button styles are disabled.

@if $enable-manual-themes and $enable-theme-switch {

@if $enable-switch-celestial {
  @if $enable-sun-svg and $enable-moon-svg {
    :where(html) {
      --switch-off: var(--sun);
      --switch-on: var(--moon);
    }
  }
  @else {
    :where(html) {
      --switch-off: #{$sun};
      --switch-on: #{$moon};
    }
  }
}

@if $enable-switch-radio {
  @if $enable-radio-off-svg and $enable-radio-on-svg {
    :where(html) {
      --switch-off: var(--radio-off);
      --switch-on: var(--radio-on);
    }
  }
  @else {
    :where(html) {
      --switch-off: #{$radio-off};
      --switch-on: #{$radio-on};
    }
  }
}

@if $enable-switch-celestial {
  #themes {
    --ico: var(--color);
    --svg: var(--switch-ico);
    --btn-px: .5rem;
    --ico-va: -.12em;
  }
  
  #themes:before {
    @extend %icon-mask;
  }
  
  #themes:after {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip: rect(0,0,0,0);
    white-space: nowrap;
  }  
  
  :where(html) {
    #themes:after {
      content: "Dark off" / "off";
    }
  }
  
  :where([data-prefers-dark=true]) {
    #themes:after {
      content: "Dark on" / "on";
    }  
  }
   
} // END [if/celestial]

@if $enable-switch-radio {
  #themes {
    --btn-bg: transparent;
    --btn-hover: transparent;
    --btn-bd-color: transparent;
    --btn-py: 0;
    --btn-px: 0;
    --ico: var(--color);
    --svg: var(--switch-ico);
    --ico-va: -.12em;
  }
  
  #themes:before {
    @extend %icon-mask;
  }
  
  #themes:hover {
    --color: var(--link-hover);
  }
  
  #themes:hover:after {
    color: var(--text);
  }
  
  #themes:after {
    margin-inline-start: .075rem;
    word-spacing: -.16em;
  }
  
  :where(html) {
    #themes:after {
      content: "Dark off"; // Firefox fallback
      content: "Dark off" / "off";
    }
  }
  
  :where([data-prefers-dark=true]) {
    #themes:after {
      content: "Dark on"; // Firefox fallback
      content: "Dark on" / "on";
      word-spacing: -.1em;
    }
  }  
} // END [if/radio]

} // END [if/theme-switch]

Both buttons are designed to be accessible using the aria-pressed attribute to indicate status as described above combined with visually hidden text to describe the button. The CSS content attributes used on both pre-styled buttons is designed to enhance accessibility but please note currently hasn't been tested beyond the author's devices so email feedback is highly welcomed.