JimmyBanks
JimmyBanks

Reputation: 4698

How to override css prefers-color-scheme setting

I am implementing a dark mode, as macOS, Windows and iOS have all introduced dark modes.

There is a native option for Safari, Chrome, and Firefox, using the following CSS media rule:

@media (prefers-color-scheme: dark) {
body {
    color:#fff;
    background:#333333
}

This will automatically identify systems that are set to dark modes, and apply the enclosed CSS rules.

However; even though users may have their system set to dark mode, it may be the case that they prefer the light or default theme of a specific website. There is also the case of Microsoft Edge users which does not (yet) support @media (prefers-color-scheme. For the best user experience, I want to ensure that these users can toggle between dark and default modes for those cases.

Is there a method that this can be performed, possibly with HTML 5 or JavaScript? I'd include the code I have tried, but I haven't been able to find any information on implementing this whatsoever!

Upvotes: 108

Views: 120863

Answers (18)

LWC
LWC

Reputation: 1166

Here's an answer that respects the default prefers-color-scheme, and only then lets you toggle via localStorage. This assumes CSS does it faster than JS plus people will use the default scheme even without JS.

I don't like having to declare a default style and then re-declaring it as a standalone class called, but it's unavoidable. I at least used :root to avoid duplicated values.

Note this forum seems to block localStorage so you have to try the code somewhere else.

var theme, prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
if (prefersDarkScheme.matches)
    theme = document.body.classList.contains("light-mode") ? "light" : "dark";
else
    theme = document.body.classList.contains("dark-mode") ? "dark" : "light";
localStorage.setItem("theme", theme);

function toggle() {
    var currentTheme = localStorage.getItem("theme");
    if (currentTheme == "dark")
        document.body.classList.toggle("light-mode");
    else if (currentTheme == "light")
        document.body.classList.toggle("dark-mode");
}
:root {
  --text-for-light: black;
  --bkg-for-light: white;
  --link-for-light: blue;
  --text-for-dark: white;
  --bkg-for-dark: black;
  --link-for-dark: DeepSkyBlue;
}

body {color: var(--text-for-light); background-color: var(--bkg-for-light);}
a {color: var(--link-for-light);}

.dark-mode {color: var(--text-for-dark); background-color: var(--bkg-for-dark);}
.dark-mode a {color: var(--link-for-dark);}
.light-mode {color: var(--text-for-light); background-color: var(--bkg-for-light);}
.light-mode a {color: var(--link-for-light);}

@media (prefers-color-scheme: dark) {
    body {color: var(--text-for-dark); background-color: var(--bkg-for-dark);}
    a {color: var(--link-for-dark);}
}
<button onclick="toggle()">Toggle Light/Dark Mode</button>
<p>&nbsp;</p>
Test <a href="link">link</a>

If you want just the auto detect part without the toggle button:

:root {
  --text-for-light: black;
  --bkg-for-light: white;
  --link-for-light: blue;
  --text-for-dark: white;
  --bkg-for-dark: black;
  --link-for-dark: DeepSkyBlue;
}

body {color: var(--text-for-light); background-color: var(--bkg-for-light);}
a {color: var(--link-for-light);}

@media (prefers-color-scheme: dark) {
    body {color: var(--text-for-dark); background-color: var(--bkg-for-dark);}
    a {color: var(--link-for-dark);}
}
Test <a href="link">link</a>

Upvotes: 6

amarinediary
amarinediary

Reputation: 5439

One issue I faced with the original solution was that the changes to the color scheme were not affecting the scrollbar color. However, this issue can be resolved by using the color-scheme CSS property in conjunction with the :root pseudo-element.

The following offers :

  • System preferences respect & overwrite.

  • Scrollbars color scheme respect.

(As of IE end of life, August 17th 2021 🥳✌️🎉)

  • Universal browser support.


In short, the idea is to store the color scheme in the browser's local storage and update the color scheme when the user's preference are changed, without reloading the page. If there is no stored color scheme, the system preference is applied.

We get the stored color scheme from the browser's local storage using window.localStorage.getItem("scheme"). This value will be null if there is no stored value. If there is no stored value, we checks if the user's device/browser prefers a dark color scheme using window.matchMedia("(prefers-color-scheme: dark)").matches.

(function(){
    // Get the toggle element by its ID
    var e = document.getElementById("tglScheme");
  
    // If the toggle element is not found, log an error and exit the function
    if (!e) { console.error('No element with ID "tglScheme" found. Unable to handle color scheme toggle.'); return; }
  
    // Check if the user's device/browser prefers a dark color scheme
    var t = window.matchMedia("(prefers-color-scheme: dark)").matches;
  
    // Get the stored color scheme from the browser's local storage
    var n = window.localStorage.getItem("scheme");
  
    // Function to apply the color scheme based on the given scheme value
    var i = function(r){
      var s = document.getElementById("scheme"); s && s.remove(); document.head.insertAdjacentHTML("beforeend",'<style id="scheme">:root{color-scheme:'+r+"}</style>");document.body.classList.remove("light","dark");document.body.classList.add(r);e.checked="dark"===r;console.log("Color scheme applied:",r);
    };
  
    // Determine the initial color scheme based on user preference or stored value
    var a = n || (t ? "dark" : "light"); i(a);
  
    // Attach a click event listener to the toggle element
    e.addEventListener("click", function(){
      // Check if the toggle is checked or not
      var r = e.checked;
  
      // Apply the new color scheme based on the toggle state
      var o = r ? "dark" : "light"; i(o);
  
      // Update the stored color scheme in the browser's local storage
      r ? (localStorage.setItem("scheme", "dark"), console.log("User preference saved: dark")) : (localStorage.setItem("scheme", "light"), console.log("User preference saved: light"));
    });
  
    // Update the color scheme when the user's preference changes, without reloading the page
    window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(){
      // Update the stored color scheme from the browser's local storage
      n = window.localStorage.getItem("scheme");
  
      // If there's no stored color scheme, apply the system preference
      if (!n) { t = window.matchMedia("(prefers-color-scheme: dark)").matches; var r = t ? "dark" : "light"; i(r); console.log("System color scheme preference changed:", r); }
    });
})();
/* Default theme variables */
:root {
  --app-bg: #ffffff;
  --app-tx: #000000;
}

/* Dark theme variables */
body.dark {
  --app-bg: #131313;
  --app-tx: #f8f9fa;
}

/* Apply the background and text colors using the variables */
body {
  background-color: var(--app-bg);
  color: var(--app-tx);
}
<label for="tglScheme">Toggle Dark Mode:</label>
<input type="checkbox" id="tglScheme" />

Visit: https://codepen.io/amarinediary/pen/yLgppWW to see it live on CodePen.

Upvotes: 5

Meanderbilt
Meanderbilt

Reputation: 73

Took the solution provided by @JimmyBanks and 1) turned the checkbox into a toggling text button, and 2) added automatic theme switching on OS theme change.

CSS is unchanged, with light themes stored in the :root and dark themes stored under [data-theme="dark"]:

The <head> JS has some edits, including a few omissions and the move of the data-theme statement to the subsequent JS block:

Please let me know if you have any suggestions for improvement!

var theme = 'light';
if (localStorage.getItem('theme')) {
  if (localStorage.getItem('theme') === 'dark') {
    theme = 'dark';
  }
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
  theme = 'dark';
}

/*
And here is the edit to the second block of JS, plus related HTML. `theme_switch` toggles the theme, while `theme_OS` automatically updates the site's theme with changes to the OS theme.
*/

var theme;
function theme_apply() {
  'use strict';
  if (theme === 'light') {
    document.getElementById('theme_readout').innerHTML = 'Dark';
    document.documentElement.setAttribute('data-theme', 'light');
    localStorage.setItem('theme', 'light');
  } else {
    document.getElementById('theme_readout').innerHTML = 'Light';
    document.documentElement.setAttribute('data-theme', 'dark');
    localStorage.setItem('theme', 'dark');
  }
}
theme_apply();
function theme_switch() {
  'use strict';
  if (theme === 'light') {
    theme = 'dark';
  } else {
    theme = 'light';
  }
  theme_apply();
}
var theme_OS = window.matchMedia('(prefers-color-scheme: light)');
theme_OS.addEventListener('change', function (e) {
  'use strict';
  if (e.matches) {
    theme = 'light';
  } else {
    theme = 'dark';
  }
  theme_apply();
});
:root {
  --color_01: #000;
  --color_02: #fff;
  --color_03: #888;
}

[data-theme="dark"] {
  --color_01: #fff;
  --color_02: #000;
  --color_03: #777;
}
<a onclick="theme_switch()">Theme: <span id="theme_readout"></span></a>

Upvotes: 5

Mendhak
Mendhak

Reputation: 8775

My answer is based on this one, but I have included changes I had to make to get it working, plus I added the ability to persist in local storage.

The important point is that it works with @media (prefers-color-scheme: dark) CSS, and there was no need to create or duplicate additional CSS classes just for this to work. In other words it works with native CSS color scheme.

On the page I first added a sun/moon icon and set them to invisible.

So on page load, the applyPreferredColorScheme(getPreferredColorScheme()) method runs, which checks the system and local storage and figures out which theme to apply. It also switches between the sun or moon icon depending on the current theme.

When the user clicks the icon to toggle the theme, toggleColorScheme() runs which stores the chosen theme in local storage, but one difference - if the user switches back to the theme that matches their OS, the code simply removes the item from local storage. Trying to keep it as native as possible.

JSFiddle: https://jsfiddle.net/35e0a97a/xmt1k659/78/

// https://stackoverflow.com/questions/56300132/how-to-override-css-prefers-color-scheme-setting
// Return the system level color scheme, but if something's in local storage, return that
// Unless the system scheme matches the the stored scheme, in which case... remove from local storage
function getPreferredColorScheme(){
  let systemScheme = 'light';
  if(window.matchMedia('(prefers-color-scheme: dark)').matches){
    systemScheme = 'dark';
  }
  let chosenScheme = systemScheme;

  if(localStorage.getItem("scheme")){
    chosenScheme = localStorage.getItem("scheme");
  }

  if(systemScheme === chosenScheme){
    localStorage.removeItem("scheme");
  }

  return chosenScheme;
}

// Write chosen color scheme to local storage
// Unless the system scheme matches the the stored scheme, in which case... remove from local storage
function savePreferredColorScheme(scheme){
  let systemScheme = 'light';

  if(window.matchMedia('(prefers-color-scheme: dark)').matches){
    systemScheme = 'dark';
  }

  if(systemScheme === scheme){
    localStorage.removeItem("scheme");
  }
  else {
    localStorage.setItem("scheme", scheme);
  }

}

// Get the current scheme, and apply the opposite
function toggleColorScheme(){
  let newScheme = "light";
  let scheme = getPreferredColorScheme();
  if (scheme === "light"){
    newScheme = "dark";
  }

  applyPreferredColorScheme(newScheme);
  savePreferredColorScheme(newScheme);


}

// Apply the chosen color scheme by traversing stylesheet rules, and applying a medium.
function applyPreferredColorScheme(scheme) {
    for (var s = 0; s < document.styleSheets.length; s++) {

        for (var i = 0; i < document.styleSheets[s].cssRules.length; i++) {
            rule = document.styleSheets[s].cssRules[i];


            if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {

                switch (scheme) {
                    case "light":
                        rule.media.appendMedium("original-prefers-color-scheme");
                        if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)");
                        if (rule.media.mediaText.includes("dark")) rule.media.deleteMedium("(prefers-color-scheme: dark)");
                        break;
                    case "dark":
                        rule.media.appendMedium("(prefers-color-scheme: light)");
                        rule.media.appendMedium("(prefers-color-scheme: dark)");
                        if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme");
                        break;
                    default:
                        rule.media.appendMedium("(prefers-color-scheme: dark)");
                        if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)");
                        if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme");
                        break;
                }
            }
        }


    }

    // Change the toggle button to be the opposite of the current scheme
    if (scheme === "dark") {
        document.getElementById("icon-sun").style.display = 'inline';
        document.getElementById("icon-moon").style.display = 'none';
    } else {
        document.getElementById("icon-moon").style.display = 'inline';
        document.getElementById("icon-sun").style.display = 'none';
    }
}

applyPreferredColorScheme(getPreferredColorScheme());
#icon-sun {
  width: 1.5rem;
  height: 1.5rem;
  display: none;
}

#icon-moon {
  width: 1.5rem;
  height: 1.5rem;
  display: none;
}
<a href="javascript:toggleColorScheme();">
      <span id="icon-sun">🌞</span>
      <span id="icon-moon">🌚</span>
</a>

Upvotes: 11

Amjad Abujamous
Amjad Abujamous

Reputation: 920

Code Pen

https://codepen.io/glorious73/pen/rNRXGex

Solution

The following is a solution that uses the media query which checks if the users prefers dark theme and sets the theme as such. Then it allows the user to switch themes and saves it in local storage for future visits.

  1. Upon application load, check if the user already chose a theme in past visits by reading local storage.
  2. If the user chose one, assign it. If, on the other hand, this is their first visit, check the system theme using window.matchMedia("(prefers-color-scheme: dark)").
  3. Assign the theme to match the system using an introduced attribute to html and then save it in local storage.
  4. Change the colors of the app purely based on the html theme.

The source code below should serve for a good example.

html

<!DOCTYPE html>
<!--To set the theme for the html element below-->
<html>
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/png" href="/img/logo.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
  </head>
  <body>
    <div id="main">
      <h1>This is a dummy text illustrating the use of themes.</h1>
      <div class="entry">
        <h2 class="key">Theme</h2>
        <div class="radios">
          <input type="radio" id="light" name="theme">
          <label class="option-one" for="light">
          Light
          </label>
          <input type="radio" id="dark" name="theme">
          <label class="option-two" for="dark">
          Dark
          </label>
        </div>
      </div>
    </div>
    <script type="module" src="/src/index.js"></script>
  </body>
</html>

Now, in index.js, here is what needs to be done to set the theme.

Javascript

class Theme {
    constructor() {
        this.selectedTheme = localStorage.getItem("theme"); // 'light' or 'dark'
    }

    static getInstance() {
        if (!this.instance)
            this.instance = new Theme();
        return this.instance;
    }

    setTheme(theme) {
        this.selectedTheme = theme;
        const html         = document.querySelector("html");
        html.setAttribute("theme", theme); // set theme here!!
        localStorage.setItem("theme", theme); // future visits
    }

    getTheme() {
        return this.selectedTheme;
    }
}

function loadTheme() {
  let selectedTheme = Theme.getInstance().getTheme();
  if(!selectedTheme) {
    const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
    selectedTheme = (prefersDarkScheme.matches) ? "dark" : "light";
  }
  Theme.getInstance().setTheme(selectedTheme);
}

loadTheme()

Now, the styles in your app should solely depend on variables which change based on the theme attribute of the html element, like so.

CSS

* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

:root {
  --primary-color: #ffffff;
  --secondary-color: #2d5c8a;
  --accent-color: #f2f2f2;
  --text-color: #1b1212;
  --white-color: #ffffff;
  --success-color: #077d0b;
  --error-color: #ff4d00;
  --box-shadow-color: rgba(0,0,0,0.2);

  color-scheme: light dark;
}

html[theme="dark"] {
  --primary-color: #242424;
  --secondary-color: #2d5c8a;
  --accent-color: #1f1f1f;
  --text-color: #ffffff;
  --white-color: #ffffff;
  --success-color: #077d0b;
  --error-color: #ff4d00;
  --box-shadow-color: rgba(212, 212, 212, 0.2);
}

body {
  background-color: var(--primary-color);
  color: var(--text-color);
}

Finally, all you need is a theme switcher to change the theme while the user is using the app. The following is an example that shows you how one can be done.

<div class="entry">
  <h2 class="key">Theme</h2>
  <div class="radios">
    <input type="radio" id="light" name="theme">
    <label class="option-one" for="light">
     Light
    </label>
    <input type="radio" id="dark" name="theme">
    <label class="option-two" for="dark">
     Dark
    </label>
  </div>
</div>
.key {
  font-weight: normal;
}

.entry {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 1rem;
}

.radios {
  display: flex;
  align-items: center;
  border: 1px solid var(--text-color);
  border-radius: 0.8rem;
  width: 10rem;
  padding: 0.5rem 0;
  padding: 0;
}

input[type=radio] {
  display: none;
}

input[type="radio" i] {
  margin: 0 !important;
  padding: 0 !important;
}

input[type=radio]+label {
  display: inline-block;
  width: 5rem;
  padding: 0.5rem 0;
  font-size: 1rem;
  text-align: center;
  cursor: pointer;
  background-color: var(--primary-color);
  color: var(--text-color);
  transition: all 0.25s ease;
}

input[type=radio]+label.option-one {
  border-top-left-radius: 0.8rem;
  border-bottom-left-radius: 0.8rem;
}

input[type=radio]+label.option-two {
  border-top-right-radius: 0.8rem;
  border-bottom-right-radius: 0.8rem;
}

input[type=radio]:hover+label {
  font-weight: bold;
}

input[type=radio]:checked+label {
  outline: 0;
  color: var(--primary-color) !important;
  background: var(--secondary-color);
}
const currentTheme = Theme.getInstance().getTheme();
const themes = document.querySelectorAll('input[name="theme"]');
themes.forEach(element => {
  if(element.id == currentTheme)
    element.setAttribute("checked", "checked");
  element.addEventListener("change", async () => await setTheme(element.id));
});

function setTheme(theme) {
 Theme.getInstance().setTheme(theme);
}

Upvotes: 1

sybb
sybb

Reputation: 194

Modifying the stylesheets is unreliable so here's another solution.

  1. Setup a media query, keeping the styles minimal:
@media (prefers-color-scheme: light) {
  html {
    filter: none;
  }
}
@media (prefers-color-scheme: dark) {
  html {
    filter: invert(100%);
  }
}
  1. Execute the following using a toggle button:
export function forceColorScheme(scheme: "light" | "dark" | null) {
  const htmlElement = document.querySelector(":root") as HTMLHtmlElement;
  switch (scheme) {
    case "light":
      htmlElement.style.filter = "none";
      break;
    case "dark":
      htmlElement.style.filter = "invert(100%)";
      break;
    default:
      htmlElement.style.removeProperty("filter");
      break;
  }
}

Depending on your use case, it may be better to replace class names instead.

Upvotes: 0

Soullivaneuh
Soullivaneuh

Reputation: 3863

React solution

Based on @some1and2 answer, here is a React solution using usehooks-ts:

import {
  FC,
  useEffect,
} from 'react';
import {
  useMediaQuery,
  useTernaryDarkMode,
} from 'usehooks-ts';

export const ThemeSwitch: FC = () => {
  const isSystemDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
  const {
    isDarkMode,
    ternaryDarkMode,
    setTernaryDarkMode,
    toggleTernaryDarkMode,
  } = useTernaryDarkMode()
  type TernaryDarkMode = typeof ternaryDarkMode

  useEffect(() => {
    for (let s = 0; s < document.styleSheets.length; s++) {
      const styleSheet = document.styleSheets.item(s);
      const cssRules = styleSheet?.cssRules || new CSSRuleList();

      for (let r = 0; r < cssRules.length; r++) {
        const cssRule = cssRules.item(r);
        if (!(cssRule instanceof CSSMediaRule)) {
          continue;
        }

        const ruleMediaText = cssRule.media.mediaText;
        if (!ruleMediaText.match(/(prefers-color-scheme: \w+)/)) {
          continue;
        }

        /**
         * Replace the prefers-color-scheme media query to apply the desired theme.
         * It refers to the system media query in order to keep the mechanic in sync by reversing it.
         */
        const newRuleMediaText = ruleMediaText.replace(
          /(dark|light)/,
          isDarkMode
            ? isSystemDarkMode ? 'dark' : 'light'
            : isSystemDarkMode ? 'light' : 'dark'
        );

        cssRule.media.deleteMedium(ruleMediaText);
        cssRule.media.appendMedium(newRuleMediaText);
      }
    }
  }, [isDarkMode, isSystemDarkMode]);

  return (
    <div>
      <p>System theme: {isSystemDarkMode ? 'dark' : 'light'}</p>
      <p>Current theme: {isDarkMode ? 'dark' : 'light'}</p>
      <p>ternaryMode: {ternaryDarkMode}</p>
      <p>
        <button onClick={toggleTernaryDarkMode}>
          Toggle from {ternaryDarkMode}
        </button>
      </p>
      <p>
        Select a mode
        <br />
        <select
          name="dark-mode-select"
          onChange={ev =>
            setTernaryDarkMode(ev.target.value as TernaryDarkMode)
          }
          value={ternaryDarkMode}
        >
          <option value="light">light</option>
          <option value="system">system</option>
          <option value="dark">dark</option>
        </select>
      </p>
    </div>
  );
}

Here some important details to notice:

  1. We use isSystemDarkMode variable, representing the system preferred scheme. It is important to reverse the switch mechanic. Otherwise, you will have a light theme being set on dark mode and vice-versa.
  2. This solution works only if you define the color schemes onto common css files. If you include separated CSS files using the media attribute of the link html element, you will need to play with document.styleSheets[*].media instead of the rules. You can refer to the GoogleChromeLabs/dark-mode-toggle project as a reference.

Upvotes: 0

some1and2
some1and2

Reputation: 149

The best solution

Having this question be asked ~4 years ago I would hope the original author no longer needs my wisdom, however other people might still face a similar issue. After reading through all previous solutions, I have come up with a solution better than everyone elses. Here is a few reasons:
  1. Very little change to the original code (you can use all your existing code, whether it be using media queries to set variables or just setting rules for attributes directly).
  2. No extra CSS classes.
  3. Uses local storage to save settings.
  4. Drag / Drop. This solution you just need to call a javascript function from a button and everything else is handled.
  5. Handles updating preferences without page reload.

This solution is loosely based on this solution https://stackoverflow.com/a/75124760/15474643 however actually functions properly (this person doesn't account for cases such as @media (prefers-color-scheme: light) {}).

Here is it:

/*
    JS file for managing light / dark themes
    The toggle_theme(); function toggles the saved theme and updates the screen accordingly
    The remove_theme(); function removes the theme from localstorage and only updates the screen if it doesn't match the system settings
    The window.matchMedia(); function call watches for updates to system settings to keep localstorage settings accurate
*/

function get_system_theme() {
    /*
        Function for getting the system color scheme
    */

    theme = "dark";
    if (window.matchMedia("(prefers-color-scheme: light)").matches) {
        theme = "light";
    }

    return theme;
}

function toggle_saved_theme() {
    /*
        Function for toggling between the two themes saved to local storage
        Returns:
            Value stored in local storage
    */

    // Gets Current Value
    if (localStorage.getItem("theme")) {
        theme = localStorage.getItem("theme");
    }
    else {
        theme = get_system_theme();
    }

    // Sets the stored value as the opposite
    if (theme === "light") {
        localStorage.setItem("theme", "dark");
    }
    else {
        localStorage.setItem("theme", "light");
    }

    return localStorage.getItem("theme");
}

function switch_theme_rules() {
    /*
        Function for switching the rules for perfers-color-scheme
        Goes through each style sheet file, then each rule within each stylesheet
        and looks for any rules that require a prefered colorscheme, 
        if it finds one that requires light theme then it makes it require dark theme / vise
        versa. The idea is that it will feel as though the themes switched even if they haven't. 
    */

    for (var sheet_file = 0; sheet_file < document.styleSheets.length; sheet_file++) {
        try {
            for (var sheet_rule = 0; sheet_rule < document.styleSheets[sheet_file].cssRules.length; sheet_rule++) {
                rule = document.styleSheets[sheet_file].cssRules[sheet_rule];

                if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
                    rule_media = rule.media.mediaText;

                    if (rule_media.includes("light")) {
                        new_rule_media = rule_media.replace("light", "dark");
                    }
                    if (rule_media.includes("dark")) {
                        new_rule_media = rule_media.replace("dark", "light");
                    }
                    rule.media.deleteMedium(rule_media);
                    rule.media.appendMedium(new_rule_media);
                }
            }
        }
        catch (e) {
            broken_sheet = document.styleSheets[sheet_file].href;
            console.warn(broken_sheet + " broke something with theme toggle : " + e);
        }
    }
}

function toggle_theme() {
    /*
        Toggles the current theme used
    */
    stored_theme = toggle_saved_theme();
    switch_theme_rules();
}

function remove_theme() {
    /*
        Function for removing theme from local storage
    */
    if (localStorage.getItem("theme")) {
        if (get_system_theme() != localStorage.getItem("theme")) {
            switch_theme_rules();
        }
        localStorage.removeItem("theme");
    }
}

window.matchMedia('(prefers-color-scheme: dark)')
    /*
        This makes it such that if a user changes the theme on their
        browser and they have a preferred theme, the page maintains its prefered theme. 
    */
    .addEventListener("change", event => {
        if (localStorage.getItem("theme")) {
            switch_theme_rules(); // Switches Theme every time the prefered color gets updated
        }
    }
)

if (localStorage.getItem("theme")) {
    if (get_system_theme() != localStorage.getItem("theme")) {
        switch_theme_rules();
    }
}
:root {
  --main-background: white;
  --text-color: black;
}

@media screen and (prefers-color-scheme: dark) {
  :root {
    --main-background: black;
    --text-color: white;
  }
}

body {
  background-color: var(--main-background);
}

* {
  color: var(--text-color);
}
<!DOCTYPE html>
<html lang="en">
  <body>
    <p>Some Text</p>
    <button onclick="toggle_theme();">Change Theme!</button>
    <button onclick="remove_theme();">Remove Theme!</button>
  </body>
</html>

About

I think that this is one of the more elegant ways of solving this problem, no duplicating css rules, no defining more css rules, no rewriting media queries to be classes. Just a function call from a button. To break this code down a bit, it basically swaps the word light / dark in media queries on certain events (changing preferences, updating stored preference etc). The only downside is that the CSS files that are being effected by this can't be from an external source (must be from the same origin).

Hope this helps someone out there!

Upvotes: 8

mdcq
mdcq

Reputation: 2026

Using CSS variables, set a default value and an opposite value in a media query. Also set the values in two classes and implement a toggle that toggles these classes when clicked.

By default, automatic mode is used based on the system color scheme. Using the toggle switches to manual mode. It returns to automatic mode after refreshing the page (or removing the class from the html element).

// toggle to switch classes between .light and .dark
// if no class is present (initial state), then assume current state based on system color scheme
// if system color scheme is not supported, then assume current state is light
function toggleDarkMode() {
  if (document.documentElement.classList.contains("light")) {
    document.documentElement.classList.remove("light")
    document.documentElement.classList.add("dark")
  } else if (document.documentElement.classList.contains("dark")) {
    document.documentElement.classList.remove("dark")
    document.documentElement.classList.add("light")
  } else {
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      document.documentElement.classList.add("dark")
    } else {
      document.documentElement.classList.add("light")
    }
  }
}
/* automatic/manual light mode */
:root, :root.light {
  --some-value: black;
  --some-other-value: white;
}

/* automatic dark mode */
/* ❗️ keep the rules in sync with the manual dark mode below! */
@media (prefers-color-scheme: dark) {
  :root {
    --some-value: white;
    --some-other-value: black;
  }
}

/* manual dark mode 
/* ❗️ keep the rules in sync with the automatic dark mode above! */
:root.dark {
  --some-value: white;
  --some-other-value: black;
}

/* use the variables */
body {
  color: var(--some-value);
  background-color: var(--some-other-value);
}
<button onClick="toggleDarkMode()">Toggle</button>
<h1>Hello world!</h1>

Upvotes: 32

Nullivex
Nullivex

Reputation: 21

I just did this today and worked with the dark mode preferences. I think this solution is simpler than all the ones I looked at here.

The toggle darkmode button is optional. Local storage is used, works with iPhone dark mode.

Finally, my research was all the posts here. I worked out an "optimized" solution. See what kind of traction you get. I included the SVG to make things easier.

EDIT: I updated the JS function to check for availability of local storage. I also moved the function variables into the function itself other than the toggle used for real-time setting storage. It is slightly longer this way but more correct and still shorter than most the others.

function checkForLocalStorage () {
  try {
    localStorage.setItem('test', 1)
    localStorage.removeItem('test')
    return true
  } catch (e) { return false }
}
const hasLocalStorage = checkForLocalStorage()
let isThemeDark = null
function toggleDarkMode () {
  const isPreferDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  const localPref = hasLocalStorage ? localStorage.getItem('isThemeDark') : null
  const hasLocalPref = !!localPref
  if (isThemeDark === null && hasLocalPref) isThemeDark = localPref === 'dark'
  else if (isThemeDark === null && isPreferDark) isThemeDark = true
  else if (isThemeDark === null) isThemeDark = false
  else isThemeDark = !isThemeDark
  const theme = isThemeDark ? 'dark' : 'light'
  if (hasLocalStorage) localStorage.setItem('isThemeDark', theme)
  document.body.classList[isThemeDark ? 'add' : 'remove']('dark-mode')
}
toggleDarkMode()
body.dark-mode { background: #222; color: #f2f2f2; }
body.dark-mode #darkModeToggle svg { fill: #fff; }
<a href="#" id="darkModeToggle" onclick="toggleDarkMode()">
  <svg width="24px" height="24px"><path d="M12,22 C17.5228475,22 22,17.5228475 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,17.5228475 6.4771525,22 12,22 Z M12,20.5 L12,3.5 C16.6944204,3.5 20.5,7.30557963 20.5,12 C20.5,16.6944204 16.6944204,20.5 12,20.5 Z"/></svg>
</a>
<div>Hello World!</div>

Upvotes: 2

Bill Ticehurst
Bill Ticehurst

Reputation: 1868

Looks like there's plenty of answers here already, but nothing quite matching my needs. I wanted to be able to:

  • Use the OS preference if no other preference is set.
  • If using the OS preference, have the page reflect changes to it instantly.
  • Allow a query string param to override the OS preference.
  • If the user explicitly sets a preference, use that and save for future use.
  • Have the checkbox on the page reflect the theme currently in effect.
  • Toggle the same checkbox is explicitly set a preference.
  • Have relatively short but clean and readable code.

My CSS looks a lot like that in other solutions. For completeness this is something like:

:root {
    /* Support light and dark, with light preferred if no user preference */
    color-scheme: light dark;

    /* default light mode */
    --bg-color: #f7f7f7;
    --text-color: #222430;

    /* dark mode overrides */
    --dark-bg-color: #222430;
    --dark-text-color: #f7f7f7;
}

.dark-theme {
    /* Use dark mode overrides when class is applied */
    --bg-color: var(--dark-bg-color);
    --text-color: var(--dark-text-color);
}

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

There is a checkbox with id 'darkThemeToggle' in the HTML. The below runs on page load. (Written in TypeScript)

function manageTheme() {
  const darkThemeCheckbox = document.querySelector("#darkThemeToggle") as HTMLInputElement;
  const agentPrefersDarkQuery = matchMedia("(prefers-color-scheme: dark)");

  function setTheme(arg?: MediaQueryListEvent | string) {
    let chosenTheme = "";

    if (typeof arg === "string") {
      // If this function is called with a string, then an explict preference has
      // been set by the user. Use that theme and save the setting for the future.
      chosenTheme = arg;
      localStorage.setItem("theme", chosenTheme);
    } else {
      // Use any saved preference, else check for query param, else any OS preference.
      chosenTheme = localStorage.getItem("theme") || 
          new URLSearchParams(window.location.search).get("theme") ||
          (agentPrefersDarkQuery.matches ? "dark" : "light");
    }

    if (chosenTheme === "dark") {
      document.documentElement.classList.add("dark-theme");
    } else {
      document.documentElement.classList.remove("dark-theme");
    }

    // Update the UX to reflect the theme that was ultimately applied.
    darkThemeCheckbox.checked = (chosenTheme === "dark");
  }

  // Whenever the user changes the OS preference, refresh the applied theme.
  agentPrefersDarkQuery.onchange = setTheme;

  // Note that the 'change' event only fires on user action, (not when set in code), which is
  // great, else this might cause an infinite loop with the code setting it in setTheme.
  darkThemeCheckbox.addEventListener('change', ev => {
    let themeChosen = darkThemeCheckbox.checked ? "dark" : "light";
    setTheme(themeChosen);
  });

  setTheme(); // Run on initial load.
}

document.addEventListener("DOMContentLoaded", manageTheme);

Upvotes: 0

Bharadwaj Giridhar
Bharadwaj Giridhar

Reputation: 1237

An alternative solution I found using the blog mybyways which is not mentioned anywhere else but works for me. This is useful only when html uses the prefers-color-scheme css media classes.

Unlike other answers, it uses the stylesheets' rules to append the class (as opposed to adding or removing "dark" or "light" from classList)

By default it takes the style of OS setting and overrides it when toggled. I tried from Google Chrome labs but it didn't work out for me.

function setPreferredColorScheme(mode = "dark") {
  console.log("changing")
  for (var i = document.styleSheets[0].rules.length - 1; i >= 0; i--) {
    rule = document.styleSheets[0].rules[i].media;
    if (rule.mediaText.includes("prefers-color-scheme")) {
      console.log("includes color scheme")
      switch (mode) {
        case "light":
          console.log("light")
          rule.appendMedium("original-prefers-color-scheme");
          if (rule.mediaText.includes("light")) rule.deleteMedium("(prefers-color-scheme: light)");
          if (rule.mediaText.includes("dark")) rule.deleteMedium("(prefers-color-scheme: dark)");
          break;
        case "dark":
          console.log("dark")
          rule.appendMedium("(prefers-color-scheme: light)");
          rule.appendMedium("(prefers-color-scheme: dark)");
          if (rule.mediaText.includes("original")) rule.deleteMedium("original-prefers-color-scheme");
          break;
        default:
          console.log("default")
          rule.appendMedium("(prefers-color-scheme: dark)");
          if (rule.mediaText.includes("light")) rule.deleteMedium("(prefers-color-scheme: light)");
          if (rule.mediaText.includes("original")) rule.deleteMedium("original-prefers-color-scheme");
      }
      break;
    }
  }
}
@media (prefers-color-scheme: light) {
   :root {
    color: pink;
    background-color: yellow;
  }
}

@media (prefers-color-scheme: dark) {
   :root {
    color: red;
    background-color: blue;
  }
}
<body>

  <button onClick="setPreferredColorScheme()">
    toggle
    </button>
</body>

Above is a working example ^

Full source: https://mybyways.com

Upvotes: 18

Exis
Exis

Reputation: 93

I suggest using SCSS. You can make it more simpler.

/* Dark Mode */
@mixin darkMixin {
    body {
        color: #fff; 
        background: #000;
    }
}

@media (prefers-color-scheme: dark) {
    @include darkMixin;
}

.darkMode {
    @include darkMixin;
}

.lightMode {
    body {
        color: #000; 
        background: #fff;
    }
}

And you can toggle/override using JavaScript. (In this example, I used jQuery to make it look easy)

// dark
$('html').removeClass('lightMode').addClass('darkMode')

// light
$('html').removeClass('darkMode').addClass('lightMode')

If you want to detect, this is the code based on JimmyBanks' one.

function isDarkTheme(){
    let theme="light";    //default to light
    if (localStorage.getItem("theme")){
        if (localStorage.getItem("theme") == "dark")
            theme = "dark"
    } else if (!window.matchMedia) {
        return false
    } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
        theme = "dark"
    }
    return theme=='dark'
}

To save the current theme, just use localStorage:

localStorage.setItem("theme", 'light')
or
localStorage.setItem("theme", 'dark')

Upvotes: 0

Abel Callejo
Abel Callejo

Reputation: 14929

TL;DR


index.html

<!DOCTYPE html>
<html>
    <head>
        <meta name="color-scheme" content="light dark">
        <link rel="stylesheet" type="text/css" href="style.css" />
    </head>
    <body>
        <h1>Hello world</h1>
        <button id="toggle">Toggle</button>
        <script type="text/javascript" src="script.js"></script>
    </body>
</html>

style.css

.dark-mode {
    background-color: black;
    color: white;
}

.light-mode {
    background-color: white;
    color: black;
}

@media (prefers-color-scheme: dark) {
    body {
        background-color: black;
        color: white;
    }
}

script.js

/**
 * Adopt:
 * the theme from the system preferences; or
 * the previously stored mode from the `localStorage`
 */
var initialMode = "light";
var prefersColorSchemeDark = window.matchMedia( "(prefers-color-scheme: dark)" );

if ( prefersColorSchemeDark.matches ) {
    initialMode = "dark";
}

if( localStorage.getItem("initialMode") == null ) {
    localStorage.setItem("initialMode", initialMode);
}

if( localStorage.getItem("currentMode") == null ) {
    localStorage.setItem("currentMode", initialMode);
} else {
    let currentMode = localStorage.getItem("currentMode");
    if ( currentMode == "dark" && currentMode != initialMode ) {
        document.body.classList.add("dark-mode");
    } else if ( currentMode == "light" && currentMode != initialMode ) {
        document.body.classList.add("light-mode");
    }
}

/**
 * Process the toggle then store to `localStorage`
 */
document.getElementById('toggle').addEventListener("click", function() {
    var initialMode = localStorage.getItem("initialMode");
    let currentMode = localStorage.getItem("currentMode");

    if ( currentMode == "dark" && currentMode == initialMode ) {
        document.body.classList.add("light-mode");
        localStorage.setItem("currentMode", "light");
    } else if ( currentMode == "light" && currentMode == initialMode ) {
        document.body.classList.add("dark-mode");
        localStorage.setItem("currentMode", "dark");
    } else if ( currentMode != initialMode ) {
        document.body.removeAttribute("class");
        if( currentMode == "dark" ) {
            localStorage.setItem("currentMode", "light");
        } else {
            localStorage.setItem("currentMode", "dark");
        }
    }
},
false);

Details

This solution assumes that:

  1. Whatever was set on the system preferences (dark/light mode), that will be acknowledged as the initial mode
  2. From the initial mode, the end-user then can toggle manually either dark mode or light mode
  3. If the system does not have a dark mode feature, the light mode theme will be used
  4. Whatever the theme (dark/light mode) that the end-user manually set previously, that will be the new initial mode on the next page reload/refresh

Upvotes: 7

keymasterr
keymasterr

Reputation: 471

I believe the best way is to natively follow system settings unless user says otherwise.

Create button in your html. And then bind three-position switch to it with js. With saving to browser's LocalStorage.

And, finally, stylize your switch element.

document.addEventListener("DOMContentLoaded", function(event) {
  switchTheme('.theme-switch');
});

function switchTheme(selector) {
  const switches = document.querySelectorAll(selector);
  // let colorTheme = localStorage.getItem('colorTheme') || 'system'; //commented to avoid security issue
  let colorTheme = 'system';

  function changeState() {
    // localStorage.setItem('colorTheme', colorTheme); //commented to avoid security issue
    document.documentElement.setAttribute('data-theme', colorTheme);
  }
  changeState();

  switches.forEach(el => {
    el.addEventListener('click', () => {
      switch (colorTheme) {
        case 'dark':
          colorTheme = 'light';
          break
        case 'light':
          colorTheme = 'system';
          break
        default:
          colorTheme = 'dark';
      }
      changeState();
    });
  });
}
:root:not([data-theme="dark"]) {
  --bg: #fff;
}
@media (prefers-color-scheme: dark) {
   :root:not([data-theme="light"]) {
    --bg: #000;
  }
}
:root[data-theme="dark"] {
  /* yep, you'll need to duplicate styles from above */
  --bg: #000;
}


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


.theme-switch:after {
  content: ': system';
}
:root[data-theme="dark"] .theme-switch:after {
  content: ': dark';
}
:root[data-theme="light"] .theme-switch:after {
  content: ': light';
}
<button class="theme-switch">Color scheme</button>

Upvotes: 1

bennybensen
bennybensen

Reputation: 95

My Solution (3 options in radio inputs: dark, system, light) adaptation of JimmyBanks and Meanderbilt Solution:

its a bit verbose I guess, but I struggled a bit to wrap my head around it

const themeSwitches = document.querySelectorAll('[data-color-theme-toggle]')

function removeColorThemeLocalStorage() {
  localStorage.removeItem('color-theme')
}

function saveColorTheme(colorTheme) {
  if (colorTheme === 'system') {
    removeColorThemeLocalStorage()
    return
  }
  localStorage.setItem('color-theme', colorTheme)
}

function applyColorTheme() {
  const localStorageColorTheme = localStorage.getItem('color-theme')
  const colorTheme = localStorageColorTheme || null
  if (colorTheme) {
    document.documentElement.setAttribute('data-color-theme', colorTheme)
  }
}

function themeSwitchHandler() {
  themeSwitches.forEach(themeSwitch => {
    const el = themeSwitch
    if (el.value === localStorage.getItem('color-theme')) {
      el.checked = true
    }

    el.addEventListener('change', () => {
      if (el.value !== 'system') {
        saveColorTheme(el.value)
        applyColorTheme(el.value)
      } else {
        removeColorThemeLocalStorage()
        document.documentElement.removeAttribute('data-color-theme')
      }
    })
  })
  applyColorTheme()
}
document.addEventListener('DOMContentLoaded', () => {
  themeSwitchHandler()
  applyColorTheme()
})

html {
  --hue-main: 220;
  --color-text: hsl(var(--hue-main), 10%, 25%);
  --color-text--high-contrast: hsl(var(--hue-main), 10%, 5%);
  --color-link: hsl(var(--hue-main), 40%, 30%);
  --color-background: hsl(var(--hue-main), 51%, 98.5%);
}

@media (prefers-color-scheme: dark) {
  html.no-js {
    --color-text: hsl(var(--hue-main), 5%, 60%);
    --color-text--high-contrast: hsl(var(--hue-main), 10%, 80%);
    --color-link: hsl(var(--hue-main), 60%, 60%);
    --color-background: hsl(var(--hue-main), 10%, 12.5%);
  }
}

[data-color-theme='dark'] {
  --color-text: hsl(var(--hue-main), 5%, 60%);
  --color-text--high-contrast: hsl(var(--hue-main), 10%, 80%);
  --color-link: hsl(var(--hue-main), 60%, 60%);
  --color-background: hsl(var(--hue-main), 10%, 12.5%);
}
    <div class="color-scheme-toggle" role="group" title="select a color scheme">
    <p>saved setting: <span class="theme-readout">...</span></p>
        <input type="radio" name="scheme" id="dark" value="dark" aria-label="dark color scheme"> <label for="dark">dark</label>
        <input type="radio" name="scheme" id="system" value="system" aria-label="system color scheme" checked="system"> <label for="system">system</label>
        <input type="radio" name="scheme" id="light" value="light" aria-label="light color scheme"> <label for="light">light</label>
    </div>

Upvotes: 0

DenverCoder9
DenverCoder9

Reputation: 2514

You can use my custom element <dark-mode-toggle> that initially adheres to the user's prefers-color-scheme setting, but that also allows the user to (permanently or temporarily) override it. The toggle works both with separate CSS files or with classes that are toggled. The README has examples for both approaches.

Upvotes: 13

JimmyBanks
JimmyBanks

Reputation: 4698

I have determined an appropriate solution, it is as follows:

CSS will use variables and themes:

// root/default variables
:root {
    --font-color: #000;
    --link-color:#1C75B9;
    --link-white-color:#fff;
    --bg-color: rgb(243,243,243);
}
//dark theme
[data-theme="dark"] {
    --font-color: #c1bfbd;
    --link-color:#0a86da;
    --link-white-color:#c1bfbd;
    --bg-color: #333;
}

The variables are then called where necessary, for example:

//the redundancy is for backwards compatibility with browsers that do not support CSS variables.
body
{
    color:#000;
    color:var(--font-color);
    background:rgb(243,243,243);
    background:var(--bg-color);
}

JavaScript is used to identify which theme the user has set, or if they have over-ridden their OS theme, as well as to toggle between the two, this is included in the header prior to the output of the html <body>...</body>:

//determines if the user has a set theme
function detectColorScheme(){
    var theme="light";    //default to light

    //local storage is used to override OS theme settings
    if(localStorage.getItem("theme")){
        if(localStorage.getItem("theme") == "dark"){
            var theme = "dark";
        }
    } else if(!window.matchMedia) {
        //matchMedia method not supported
        return false;
    } else if(window.matchMedia("(prefers-color-scheme: dark)").matches) {
        //OS theme setting detected as dark
        var theme = "dark";
    }

    //dark theme preferred, set document with a `data-theme` attribute
    if (theme=="dark") {
         document.documentElement.setAttribute("data-theme", "dark");
    }
}
detectColorScheme();

This javascript is used to toggle between the settings, it does not need to be included in the header of the page, but can be included wherever

//identify the toggle switch HTML element
const toggleSwitch = document.querySelector('#theme-switch input[type="checkbox"]');

//function that changes the theme, and sets a localStorage variable to track the theme between page loads
function switchTheme(e) {
    if (e.target.checked) {
        localStorage.setItem('theme', 'dark');
        document.documentElement.setAttribute('data-theme', 'dark');
        toggleSwitch.checked = true;
    } else {
        localStorage.setItem('theme', 'light');
        document.documentElement.setAttribute('data-theme', 'light');
        toggleSwitch.checked = false;
    }    
}

//listener for changing themes
toggleSwitch.addEventListener('change', switchTheme, false);

//pre-check the dark-theme checkbox if dark-theme is set
if (document.documentElement.getAttribute("data-theme") == "dark"){
    toggleSwitch.checked = true;
}

finally, the HTML checkbox to toggle between themes:

<label id="theme-switch" class="theme-switch" for="checkbox_theme">
    <input type="checkbox" id="checkbox_theme">
</label>

Through the use of CSS variables and JavaScript, we can automatically determine the users theme, apply it, and allow the user to over-ride it as well. [As of the current time of writing this (2019/06/10), only Firefox and Safari support the automatic theme detection]

Upvotes: 125

Related Questions