Direct link to this sectionIntroduction
Just want the code? You can find it in my gitrepo, or test the live Demo online.
When I was programming my blog two years ago, I was thinking intensively about how to create the perfect dark mode switch. However, I had the feeling that I was missing a certain piece of the puzzle to fully understand the concept behind the color-scheme CSS property, namely a hint in the documentation that confirms my assumption about the behaviour of the property.
After I accidentally came across an updated version of the MDN Web Docs these days, which actually confirms my assumptions at the time, I took another look at the topic The proper way to create a Dark Mode Switch, I never published but now I would like to share in this blog post.
When I reference
prefers-color-scheme
property, I actually mean the user indicated preference for color themes through an operating system or user agent setting (e.g. light or dark mode).
Direct link to this sectionChallenges
For the perfect dark mode switch, the following challenges need to be considered:
- Avoid any reflows
- Avoid Flash of unstyled content (FOUC)
- Avoid duplicate CSS/SCSS code
- Avoid conflicts between
prefers-color-scheme
and selectedvalue
from switch
Requirements
The following requirements should be met regarding user events
:
- on: initial page visit:
- If a user
prefers-color-scheme
, this should be displayed. - If a user does not prefer a
color-scheme
, the default, i.e. 'light', should be displayed.
- If a user
- on: dark mode switch change:
- If a user interacts with the switch, it should display the
color-scheme
accordingly to the selectedcolor-scheme
from the switch (i.e. should override theprefers-color-scheme
property).
- If a user interacts with the switch, it should display the
- on: subsequent page visits:
- If a user has interacted with the dark mode switch, the selected
color-scheme
should be displayed (i.e. should override theprefers-color-scheme
property). - If a user has not interacted with the dark mode switch, it should fall back to the behaviour of the initial page visit.
- If a user has interacted with the dark mode switch, the selected
- on:
prefers-color-scheme
change (while visiting the page):- If a user has interacted with the dark mode switch and changes
prefers-color-scheme
property (through system or user agent setting), the previously saved selection from switch must be discarded because it is no longer valid. The page should display theprefers-color-scheme
accordingly. - If a user has not interacted with the dark mode switch and changes his system/browser-wide preference, the page should display the
prefers-color-scheme
accordingly.
- If a user has interacted with the dark mode switch and changes
Direct link to this sectionThe color-scheme
CSS property
The color-scheme CSS property allows an element to indicate which color schemes it can comfortably be rendered in.
Here is a basic example of styling based on color schemes:
/*
* The page supports both light and dark color schemes.
*/
:root {
color-scheme: light dark;
}
/*
* The page only supports light color scheme.
*/
:root {
color-scheme: only light;
}
/*
* The page only supports dark color scheme.
*/
:root {
color-scheme: only dark;
}
Direct link to this sectionThe color-scheme
meta tag
To aid user agents in rendering the page background with the desired color scheme immediately, a color-scheme
value can also be provided in a <meta name="color-scheme">
element.
Taken from Standard metadata names:
This works at the document level in the same way that the CSS
color-scheme
property lets individual elements specify their preferred and accepted color schemes.
<!-- The page supports both light and dark color schemes. -->
<meta name="color-scheme" content="light dark" />
<!-- The page only supports light color scheme. -->
<meta name="color-scheme" content="only light" />
<!-- The page only supports dark color scheme. -->
<meta name="color-scheme" content="only dark" />
Direct link to this sectionThe prefers-color-scheme
CSS property
The prefers-color-scheme CSS media feature is used to detect if a user has requested light or dark color themes (preference through an operating system or user agent).
This is just an example using both values. Sure this could be written shorter.
@media (prefers-color-scheme: light) {
:root {
background-color: white;
color: black;
}
}
@media (prefers-color-scheme: dark) {
:root {
background-color: black;
color: white;
}
}
Direct link to this sectionThe Complete Code
<!DOCTYPE html>
<html lang="en" data-theme="light">
<script>
const root = document.documentElement
const initialTheme = ('theme' in localStorage)
? localStorage.getItem('theme')
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
if (initialTheme !== 'light') {
root.setAttribute('data-theme', 'dark')
}
</script>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Light/dark mode switch</title>
<meta name='color-scheme' content='light dark' />
<style>
:root[data-theme='light'] {
color-scheme: light;
--color-bg: #fff;
--color-text: #000;
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #000;
--color-text: #fff;
}
html {
background-color: var(--color-bg);
color: var(--color-text);
}
</style>
</head>
<body>
<h1>Light/dark mode switch</h1>
<button type="button" id="theme-toggle">Toggle color-scheme</button>
<script>
document.addEventListener("DOMContentLoaded", (event) => {
document.getElementById('theme-toggle').addEventListener('click', () => {
const nextTheme =
(getComputedStyle(root).getPropertyValue('color-scheme') ===
'light')
? 'dark'
: 'light'
localStorage.setItem('theme', nextTheme)
root.setAttribute('data-theme', nextTheme)
})
window.matchMedia('(prefers-color-scheme: dark)').addEventListener(
'change',
(event) => {
localStorage.removeItem('theme')
root.setAttribute(
'data-theme',
event.matches ? 'dark' : 'light',
)
},
)
})
</script>
</body>
</html>
Direct link to this sectionCode Breakdown
Direct link to this sectionThe color-scheme
meta tag
The page supports both light and dark color schemes:
<meta name="color-scheme" content="light dark" />
Direct link to this sectionCombining color-scheme
and prefers-color-scheme
Since it is unfortunately not possible to overwrite prefers-color-scheme
, we use a dataset property to work around. This approach eliminates the need for duplicate CSS/SCSS code completely.
And here is my missing piece of the puzzle from the MDN Web docs:
... the
color-scheme
property enables overriding a user's color scheme... Forcing ... to only use a light or dark color scheme can be done by setting thecolor-scheme
property to light or dark.
<style>
:root[data-theme='light'] {
color-scheme: light;
--color-bg: #fff;
--color-text: #000;
}
:root[data-theme='dark'] {
color-scheme: dark;
--color-bg: #000;
--color-text: #fff;
}
html {
background-color: var(--color-bg);
color: var(--color-text);
}
</style>
This is hard to understand. Basically it means that color-scheme
exclusively determines the default appearance, whereas prefers-color-scheme
determines the stylable appearance.
Direct link to this sectionExploiting JavaScript's bad properties for profit
The <html>
tag represents the root of an HTML document. To handle all the challenges, we define a dataset or data-attribute data-theme
on the document root. Since we know that the default value of the theme will always be light
, we can set this initially.
As the JavaScript and CSS code is critical, it must be inlined. There is no other option!
- User
prefers-color-scheme=light
or switchedcolor-scheme=light
, we do nothing. - User
prefers-color-scheme=dark
or switchedcolor-scheme=dark
, we change the value.
Since we use data-theme
on the documentElement
and the inline JavaScript code is executed even before the painting of the <head>
element starts, it is not possible for side effects such as reflow or FOUC to occur.
<html lang="en" data-theme="light">
<script>
const root = document.documentElement
const initialTheme = ('theme' in localStorage)
? localStorage.getItem('theme')
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
if (initialTheme !== 'light') {
root.setAttribute('data-theme', 'dark')
}
</script>
<head>
<!--
It's ok. Please believe me.
https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
-->
</head>
Direct link to this sectionAdding Event Listeners
The final code adds the required event listeners on the DOMContentLoaded
event.
The onClick
event from the theme-toggle
button will fire a callback when the user toggles the theme. We check the current color-scheme
value, equivalent human readable: document.documentElement.style.colorScheme
and set it to the opposite color-scheme
accordingly. To persist the selection we store the selected value in the localStorage
.
The change
event from the window.matchMedia
event will fire when the user toggles their system/user agent preference. The callback removes the persistent value from localStorage
and changes the prefers-color-scheme
accordingly.
<script>
document.addEventListener("DOMContentLoaded", (event) => {
document.getElementById('theme-toggle').addEventListener('click', () => {
const nextTheme =
(getComputedStyle(root).getPropertyValue('color-scheme') ===
'light')
? 'dark'
: 'light'
localStorage.setItem('theme', nextTheme)
root.setAttribute('data-theme', nextTheme)
})
window.matchMedia('(prefers-color-scheme: dark)').addEventListener(
'change',
(event) => {
localStorage.removeItem('theme')
root.setAttribute(
'data-theme',
event.matches ? 'dark' : 'light',
)
},
)
})
</script>