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-schemeproperty, 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-schemeand selectedvaluefrom 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-schemeaccordingly to the selectedcolor-schemefrom the switch (i.e. should override theprefers-color-schemeproperty).
- 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-schemeshould be displayed (i.e. should override theprefers-color-schemeproperty). - 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-schemechange (while visiting the page):- If a user has interacted with the dark mode switch and changes
prefers-color-schemeproperty (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-schemeaccordingly. - 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-schemeaccordingly.
- 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-schemeproperty 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-schemeproperty enables overriding a user's color scheme... Forcing ... to only use a light or dark color scheme can be done by setting thecolor-schemeproperty 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=lightor switchedcolor-scheme=light, we do nothing. - User
prefers-color-scheme=darkor 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>