Dark Mode Toggles Should be a Browser Feature


Mockup of a browser in Dark Mode + a Light Mode override button (🌕) which the website on display takes into account. Try out the prototype below.

When implementing Dark Mode and ways to toggle it, developers currently need to duplicate code and roll their own toggle implementation.

What if, instead, browsers would take care of all that?

~

Table of Contents

~

# Dark Mode 101

Thanks to Media Queries Level 5 we can implement Dark Mode using only CSS. By also using CSS Custom Properties in the mix, it becomes very manageable:

html {
    color-scheme: light dark; /* This site supports both light and dark mode */
}

:root { /* Default Light Mode colors */
    --primary-color: black;
    --primary-background: white;    
}

@media(prefers-color-scheme: dark) {
    :root { /* Dark Mode colors */
        --primary-color: white;
        --primary-background: black;    
    }
}

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

Depending on the System/OS Color Theme setting, the site will either use black text on a white background (Light Mode) or white text on a black background (Dark Mode).

~

# The problem with Dark Mode Toggles

To offer users an easy way to switch between Light and Dark Mode, a lot of developers are offering toggle buttons their website. Instead of having the user go to the System Preferences, they can switch between Light/Dark Mode without ever leaving the site.

Developers who ever did implement a Light/Dark Mode toggle, might have noticed these side-effects/quirks:

  1. To make this work, developers need to rely on either the Checkbox Hack or JavaScript to alter something – mostly a class or attribute – on the document:

    <select name="color-scheme-">
         <option value="system">System</option>
         <option value="light">Forced Light</option>
         <option value="dark">Forced Dark</option>
     </select>
    
    document.querySelector('color-scheme').addEventListener('change', (e) => {
         document.documentElement.setAttribute('data-force-color-mode', e.target.value);
     });
  2. Because of the way it is implemented (see step 1), duplication of the CSS code is required:

    :root,
     :root[data-force-color-mode="light"] { /* Default Light Mode colors + Forced Light Mode */
         --primary-color: black;
         --primary-background: white;    
     }
    
     /* Dark Color Scheme (System Preference) */
     @media (prefers-color-scheme: dark) {
         :root {
             --primary-color: white;
             --primary-background: black;    
         }
     }
     /* Dark Color Scheme (Override) */
     :root[data-force-color-mode="dark"] {
         --primary-color: white;
         --primary-background: black;    
     }
    
  3. To persist the setting across pages and reloads, JavaScript is required:

    • Write the selected value after changing it (Most likely a cookie or Local Storage)
    • Read the value on page load, to make sure the proper override class is set on the document
  4. You'll need some more JavaScript as well to have the page respond to changes of the System Preference.

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
     mediaQuery.addListener(() => {
         // Make sure the dropdown is up-to-date based on mediaQuery.matches
     });
    

    But then again: do you still respond if the override has been set?

Above that many implementations I have seen don't take the "System" value into account. By omitting this option, the sites will never be able to respond to the system preference again, as they always have an override applied.

~

# Code Duplication as a red flag

One the things that immediately stands out as a red flag is the Code Duplication part. It's been bothering me before, and it is still bothering me today. The other listed issues are also issues, but are somewhat acceptable.

Thinking of ways to solve the Code Duplication part, I see these ideas floating around:

  • If we had a :media() pseudo-class available (see this proposal by Lea Verou), they could query it inside :is()

    :root:is(.dark, :media(prefers-color-scheme: dark)) {
          --primary-color: white;
          --primary-background: black;    
      }
    
  • In similar fashion, if Media Queries could also be used as selectors (see this suggestion), you can also group the selectors together

    @media (prefers-color-scheme: dark) p,
    .dark-mode p {
          --primary-color: white;
          --primary-background: black;    
      }
    

    (Sidenote: I’m not a fan of this suggestion to be honest)

  • If we were able to programmatically change the value for prefers-color-scheme, the duplicated CSS part could be omitted. Something like:

    document.querySelector('color-scheme').addEventListener('change', (e) => {
          window.setMedia('prefers-color-scheme', e.target.value);
      });
    

    UPDATE: Also see this CSSWG issue

These solutions, however, do not tackle any of the other listed issues. What about the JS requirement to respond to toggle changes, persist the value, read the value on load, …?

~

# A better way of toggling

What if, instead of having developers try and implement their own Dark Mode Toggle with all its intricacies, the browser would offer this functionality to the user?

Think of a button that is part of the browser's UI that you could click. Something like the button Chrome DevTools already sports, but then part of the browser UI, available for all users


Video of the feature as it appears in Chrome DevTools

With such a button in place, the user can easily toggle the preference, and developers don't need to do anything to offer easy switching.

Above that it would allow users to have their OS set to Dark Mode, while reading websites in Light Mode (or vice versa)

~

# Inherited Settings

The way I see it, the chosen Dark/Light value would be persisted on a per-site basis – i.e. altering it would store the chosen preference for only that site.

To not force users to set the value for every new site they visit, there would also be a setting at the browser level.

Combined, you'd get a value that inherits down from the system level down to the site level:

  1. Dark/Light Setting at the OS level
  2. Dark/Light Setting at the Browser level (default value: inherit from OS)
  3. Dark/Light Setting at the Site level (default value: inherit from Browser)

With these in place, users get the freedom to have the sites they visit use a different color scheme from their OS (if they want) and to also make exceptions for certain websites.

UPDATE: Also see this Chromium issue

~

# Demo

You can play around with the CodePen below to see how altering these settings at the various levels would propagate onto a dummy browser UI + two webpages:

See the Pen Browser Level Dark Mode Toggle by Bramus (@bramus) on CodePen.

Be sure to try out the values to OS:Light+Browser:Dark+Site:Light for bram.us, and then switch tabs to other.site:

  • The site bram.us will be Light, due to the Site setting being Light
  • The site other.site will be Dark, due to the Browser setting being Dark and its specific Site setting being inherit

👏 Shout out to Firefox for shipping a Dark/Light Setting at the Browser level in their Firefox 100 release. They also have an extra option, where the chosen browser theme can decide whether to apply Light or Dark Mode.

Now let's hope we can also get a setting at the Site Level?

~

# In Summary

In summary, I think there should be easier ways to switch Dark Mode on a per-site basis. Ideally, switching/overriding should be a simple button (or the like) at the browser level. That way we can get rid of all those custom light/dark mode buttons, and the browsers can sync the preference values to all the devices a user uses.

Should authors want to roll their own custom button, then a way to programmatically change the prefers-color-scheme value from within JavaScript would be a welcome addition (csswg-drafts/issues#6517). That way the duplicated CSS code block can be removed and there would be less JS involved to achieve the act of switching:

window.setMedia('prefers-color-scheme', 'dark');

In this scenario, calls to window.setMedia() would bubble up to the browser (so they can keep their UI in sync) and the browser itself would persist the chosen preference for that site.

Let me know what you think in the comments, or hit me up on Twitter.

~

# Spread the word

To help spread the contents of this post, feel free to retweet the announcement tweet:

~

Published by Bramus!

Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)

Unless noted otherwise, the contents of this post are licensed under the Creative Commons Attribution 4.0 License and code samples are licensed under the MIT License

Join the Conversation

9 Comments

  1. It’s sad that UI color has regressed more than 30 years.

    In the ’90s, you could set up a system-wide color scheme in Windows and Unix GUIs, which all properly-written applications (including browsers) would inherit and use. It was easy for developers to implement, and easy for users to set up a non-inverse (“dark”) color scheme.

    Now that everyone has realized that inverse color schemes suck, Microsoft has REMOVED the color-scheme editor from Windows. The vaunted Apple GUI never even supported user-defined color schemes.

    Now everyone is running around hard-coding a whopping TWO color schemes, and doing it poorly. People are not getting smarter, folks.

  2. how’s that there’s no reference to the color-scheme meta / property (web.dev/color-scheme)?
    it does take a step in the direction of controlling scheme at the page scope.
    problem I’ve ran into with it is that it, also, cannot be queried from a css level.
    we can query what OS preference user has set, but not what the color-scheme has set

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.