Skip to content


Browse files Browse the repository at this point in the history
Greatly simplify theme toggle logic
  • Loading branch information
AleksandrHovhannisyan committed Nov 19, 2023
1 parent 04a65e1 commit f4450c7
Show file tree
Hide file tree
Showing 2 changed files with 19 additions and 37 deletions.
54 changes: 18 additions & 36 deletions src/_includes/js/themeToggle.js
@@ -1,51 +1,40 @@
// Since this script gets put in the <head>, wrap it in an IIFE to avoid exposing variables
(function () {
// Enum of supported themes. Not strictly needed; just helps avoid typos and magic strings.
const Theme = {
LIGHT: 'light',
DARK: 'dark',
const Theme = { LIGHT: 'light', DARK: 'dark' };
// We'll use this to write and read to localStorage and save the theme as a data- attribute
const THEME_STORAGE_KEY = 'theme';
// :root will own the data- attribute for the current theme override; it is the only eligible theme owner when this script is parsed in <head>
const THEME_OWNER = document.documentElement;

// Check to see if the user previously set a site theme preference.
const cachedTheme = localStorage.getItem(THEME_STORAGE_KEY);
const isValidCachedTheme = !!cachedTheme && new Set(Object.values(Theme)).has(cachedTheme);
// If they did and the theme is valid, toggle data attribute immediately to prevent theme flash. This should never be false, but it could be if a user tampers with localStorage directly
if (isValidCachedTheme) {
if (cachedTheme) {
// If they did, toggle data attribute immediately to prevent theme flash.
THEME_OWNER.dataset[THEME_STORAGE_KEY] = cachedTheme;
} else {

// Run this only after DOM parsing so we can grab refs to elements. Putting this code here so it's co-located with the above logic.
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
if (!themeToggle) return;

// System preference (not app preference). We'll use this to listen for system preference changes so we can sync the button aria-pressed state. This will only be used in the case where a user never set an app preference (by clicking the toggle); we'll stop listening as soon as they do. Only checking dark mode to mirror CSS (where we assume light is default and override it with prefers-color-scheme queries).
const darkThemePreference = window.matchMedia('(prefers-color-scheme: dark)');
// For the case where a user never set a color preference for the site
let darkThemeSystemPreference;

/** Updates the toggle button's pressed state to reflect current theme. Since only themes are light/dark, I'm treating it as a binary toggle. */
/** Updates the toggle button's pressed state to reflect current theme. Since the only supported themes are light/dark, I'm treating it as a binary toggle. */
const setIsTogglePressed = (isPressed) => themeToggle.setAttribute('aria-pressed', isPressed);

/** Updates UI to reflect the given theme override. */
const setTheme = (theme) => {
setIsTogglePressed(theme === Theme.DARK);

/** Called when a user clicks the theme toggle. Updates UI to reflect the new theme and persists the preference in storage. */
const toggleTheme = () => {
// Not the best idea to store state in client-side UI since it can be tampered with, but this is a harmless script
const currentTheme = THEME_OWNER.dataset[THEME_STORAGE_KEY];
const newTheme = currentTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT;
const oldTheme = THEME_OWNER.dataset[THEME_STORAGE_KEY];
const newTheme = oldTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT;
setIsTogglePressed(newTheme === Theme.DARK);
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
// As soon as the user opts into a site preference, stop listening to system preference
darkThemePreference.removeEventListener?.('change', handleSystemDarkThemePreferenceChange);
darkThemeSystemPreference?.removeEventListener?.('change', handleSystemDarkThemePreferenceChange);

/** Given a user's system theme preference (from matchMedia API), updates theme state. */
Expand All @@ -54,20 +43,13 @@

const initializeTheme = () => {
// If user previously expressed preference by toggling the button, sync UI
if (isValidCachedTheme) {
// We couldn't do this before since the DOM hadn't been parsed yet, but we can (and should) do it now.
setIsTogglePressed(cachedTheme === Theme.DARK);
} else {
// User never chose a theme, so fall back to system preference. Watch for changes and sync UI. This is for the rare edge case where a user changes preference while on my site. Note: Using optional chaining since addEventListener is only available on Safari 14+.
darkThemePreference.addEventListener?.('change', handleSystemDarkThemePreferenceChange);
// Call manually once to sync initial state on load
if (!cachedTheme) {
darkThemeSystemPreference = window.matchMedia('(prefers-color-scheme: dark)');
darkThemeSystemPreference.addEventListener?.('change', handleSystemDarkThemePreferenceChange);

// Set initial pressed state and listen for manual toggles
setIsTogglePressed(cachedTheme === Theme.DARK || !!darkThemeSystemPreference?.matches);
themeToggle.addEventListener('click', toggleTheme);
2 changes: 1 addition & 1 deletion src/_includes/navbar.html
Expand Up @@ -27,7 +27,7 @@
{%- endfor -%}
<button id="theme-toggle" class="lamp" aria-label="Enable dark mode theme" type="button">
<button id="theme-toggle" class="lamp" aria-label="Enable dark theme" type="button">
<span class="lamp-base"></span>
<span class="lamp-neck"></span>
<span class="lamp-head"></span>
Expand Down

0 comments on commit f4450c7

Please sign in to comment.