Dark mode - how to handle the auto preference

Viewed 755

In this darkmode code I allow the user to set the theme through system preferences while also allowing the user to override the system preferred theme by toggling the button or radio.

How do I set checked state for the system default radio when the user changes their system preference to auto?

Since it changes prefers-color-scheme to either light or dark how would I detect the user changing their preference to auto in order to update the radio's checked state? I tried adding the checked state inside of if (window.matchMedia("(prefers-color-scheme: no-preference)").matches) {} but it didn't execute.

Also I can't figure out why my window.addEventListener("load", (e) => {} isn't detecting the user selected theme on load. It's defaulting to light even when the page is refreshed and the theme in settings is set to dark.

https://codepen.io/moofawsaw/pen/VwmaPMV

$(document).ready(function() {
  function setLight() {
    $("body").removeClass("dark-theme");
    $("body").addClass("light-theme");
    $("#light").prop("checked", true);
    $('input[name="theme"]').change();
  }

  function setDark() {
    $("body").addClass("dark-theme");
    $("body").removeClass("light-theme");
    $("#dark").prop("checked", true);
    $('input[name="theme"]').change();
  }

  function setMode() {
    if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
      $("#dark").prop("checked", true);
      $('input[name="theme"]').change();
      setDark();
    } else {
      $("#light").prop("checked", true);
      $('input[name="theme"]').change();
      setLight();
    }
  }
  //Check the mode on load and style accordingly
  if (localStorage.getItem("mode") == "dark-theme") {
    setDark();
  } else {
    setLight();
  }
  //Check for when system default is changed -> change theme
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      if (localStorage.getItem("mode") !== null) {
        //Manually set so don't do anything
      } else {
        setMode();
      }
    });
  window.addEventListener("load", (e) => {
    //If the mode is mannually set on load - choose that mode
    if (localStorage.getItem("mode") !== null) {
      // Do nothing the mode has been set manually
    } else {
      // Set the mode to the default
      setMode();
    }
  });
  //add toggle
  $("#toggleTheme").on("click", function() {
    if ($("body").hasClass("dark-theme")) {
      localStorage.setItem("mode", "light-theme");
      setLight();
    } else {
      localStorage.setItem("mode", "dark-theme");
      setDark();
    }
  });
  $("#light").on("click", function() {
    localStorage.setItem("mode", "light-theme");
    setLight();
  });
  $("#dark").on("click", function() {
    localStorage.setItem("mode", "dark-theme");
    setDark();
  });
  $("#default").on("click", function() {
    localStorage.removeItem("mode");
    if (localStorage.getItem("mode") !== null) {
      setMode();
    }
  });
});

//For selecting radio
$('input[name="theme"]').on("change", function() {
  if ($(this).is(":checked")) {
    $("label.checked").removeClass("checked");
    $(this).closest("label").addClass("checked");
  }
});
body {
  --font-color: blue;
  --bg-color: white;
  --bg-span: #ececec;
}

body.dark-theme {
  --font-color: white;
  --bg-color: black;
  --bg-span: white;
}

@media (prefers-color-scheme: dark) {
  body {
    --font-color: white;
    --bg-color: black;
    --bg-span: white;
  }
  body.light-theme {
    --font-color: blue;
    --bg-color: white;
    --bg-span: #ececec;
  }
}

body {
  color: var(--font-color);
  background: var(--bg-color);
}

button {
  padding: 0.3rem 0.9rem;
  outline: none;
  color: var(--font-color);
  background: var(--bg-color);
}

span {
  padding: 0.9rem;
  color: var(--font-color);
  background: var(--bg-span);
}

img {
  max-width: 190px;
}


/*input {
  display: none;
}*/

label {
  overflow: hidden;
  display: flex;
  flex-direction: column;
  margin-right: 0.9rem;
  border: 1px solid whitesmoke;
  border-radius: 9px;
}

label.checked {
  border: 3px solid blue;
}

.theme {
  display: flex;
}

.buttons,
.theme {
  padding: 1.3rem;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="buttons">
  <button id="toggleTheme">Mode</button>
</div>

<div class="theme">
  <label for="light">
    <img src="https://github.githubassets.com/images/modules/settings/color_mode_light.svg">
    <input type="radio" name="theme" id="light">
    <span>Light</span>
  </label>
  <label for="dark">
    <img src="https://github.githubassets.com/images/modules/settings/color_mode_dark.svg">
    <input type="radio" name="theme" id="dark">
    <span>Dark</span>
  </label>
  <label for="default">
    <img src="https://github.githubassets.com/images/modules/settings/color_mode_auto.svg">
    <input type="radio" name="theme" id="default">
    <span>System Default</span>
  </label>
</div>

1 Answers

The feature no-preference of the prefers-color-scheme @media rule is deprecated and that's why it does not work any more on latest browsers.

The load window event does execute but it doesn't have the appropriate logic in your code. Your code does nothing if the mode local storage setting is not null, which of course is the case after selecting something.

You should have the following behaviours regarding mode setting:

  1. light for light scheme that will override system/browser setting
  2. dark for dark scheme that will override system/browser setting
  3. Anything elese other than dark/light, including null/undefined, will apply what browser setting has, which it can have its own override or reading the OS setting

Also, setLight/setDark procedures should just apply the theme and not alter any input elements state because it will end up with circular value setting bugs:

function setLight() {
  $("body").removeClass("dark-theme");
  $("body").addClass("light-theme");
}

function setDark() {
  $("body").addClass("dark-theme");
  $("body").removeClass("light-theme");
}

Instead, you can have all the inputs change logic inside setMode which should check mode setting and handle all logic like this:

function setMode() {
  // have a variable to indicate what mode
  // we should apply at the end of the procedure
  let lightMode;

  // always unregister media listener,
  // because we only need it if mode is not set,
  // which then we'll have to read media queries using matchMedia
  unregisterMediaListener();

  switch (localStorage.getItem("mode")) {
    case 'light-theme': {
      lightMode = true;
      $("#light").prop("checked", true);
      break;
    }
    case 'dark-theme': {
      lightMode = false;
      $("#dark").prop("checked", true);
      break;
    }
    default: { // using system setting
      $("#default").prop("checked", true);
      // set lightMode indicator using the matchMedia query result
      lightMode = window.matchMedia("(prefers-color-scheme: light)").matches;
      // and register media change listener for changes
      registerMediaListener();
    }
  }

  // allpy new input values
  $('input[name="theme"]').change();

  // set the actual mode
  if (lightMode) {
    setLight();
  } else {
    setDark();
  }
}

And finally the matchMedia prefers-color-scheme: light change listener:

// we set a mediaMatch variable so we can use it and unregister the listener
let mm;

function registerMediaListener() {
  mm = window.matchMedia("(prefers-color-scheme: light)");
  mm.addEventListener('change', onLightSchemeChange);
}

function unregisterMediaListener() {
  if (mm) mm.removeListener(onLightSchemeChange);
}

function onLightSchemeChange(scheme) {
  if (scheme.matches) {
    // We have browser/system light scheme
    setLight();
  } else {
    setDark();
  }
}

Working example

You can check this forked and modified pen that is actually working and does select the appropriate setting/option when the page loads: https://codepen.io/clytras/pen/MWberor

Related