Supporting dark mode in web pages
Light-on-dark color schemes (also called dark mode, night mode, or dark themes) have become popular in user interfaces. This page describes various techniques that I've used to add support for dark mode to web pages and web apps.
Matching the system theme
In mid-2019, almost all major browsers1 added support for the
prefers-color-scheme
CSS media feature. prefers-color-scheme
takes the value dark
or light
depending on the user's operating-system-level dark mode setting.
web.dev recommends creating separate dark.css
and light.css
stylesheets that set
CSS variables on
the :root
pseudoclass:
/* dark.css */
:root {
--color: white;
--background-color: black;
}
/* light.css */
:root {
--color: black;
--background-color: white;
}
The main stylesheet then uses those variables when styling the page, and also sets the color-scheme CSS property to tell the browser to style components and customize the user-agent stylesheet appropriately based on the system setting:
/* main.css */
body {
color: var(--color);
background-color: var(--background-color);
color-scheme: light dark;
}
In the HTML file, media queries tell the browser to use the appropriate stylesheet:
<link rel="stylesheet" href="/dark.css" media="(prefers-color-scheme: dark)" />
<link rel="stylesheet" href="/light.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="/main.css" />
Letting the user toggle the theme
The above technique works and doesn't require any JavaScript, but I wanted to give users the ability to toggle between the light and dark themes:
- Due to accessibility concerns or lighting conditions, the user might have trouble reading the page when using the automatically-chosen theme.
- The user's operating system might not let them enable dark mode, resulting in the browser never setting
prefers-color-scheme
todark
.
I figured that it would also be nice if the user's preference were saved so it could be applied if they came back to the page later or visited other pages.
In nup, a music player app that I wrote, I had already written
code to support storing user
preferences using localStorage.
I used it (via the config
object below) to set a data-theme
attribute on the
<document>
element that is initially based on prefers-color-scheme
but can
be overriden by the preference:
const darkMediaQuery = '(prefers-color-scheme: dark)';
const updateTheme = () => {
let dark = false;
switch (config.get(Pref.THEME)) {
case Theme.AUTO:
dark = window.matchMedia(darkMediaQuery).matches;
break;
case Theme.LIGHT:
break;
case Theme.DARK:
dark = true;
break;
}
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
else document.documentElement.removeAttribute('data-theme');
};
config.addCallback((k, _) => k === Pref.THEME && updateTheme());
window.matchMedia(darkMediaQuery).addListener((e) => updateTheme());
updateTheme();
In the stylesheet, I set CSS variables based on the data-theme
attribute and used them to style
the app:
:root {
--bg-color: #fff;
--text-color: #000;
}
[data-theme='dark'] {
--bg-color: #222;
--text-color: #ccc;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
This worked well, but I noticed that I would often briefly see a jarring white page when loading app with
the dark theme enabled due to the code that sets the data-theme
attribute running
asynchronously. To work around this, I duplicated updateTheme
's logic in a <script>
tag at the top of the <body>
element so that data-theme
would already be set
when the page is first rendered:
<body>
<script>
(() => {
const config = localStorage.getItem('config');
const pref = JSON.parse(config ?? '{}').theme ?? 0; // Pref.THEME
if (
pref === 2 || // Theme.DARK
(pref === 0 && window.matchMedia('(prefers-color-scheme: dark)').matches) // Theme.AUTO
) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<!-- ... -->
</body>
Dumbing it down for older browsers
For the static website that you're reading now, I want to maintain broader support for old browsers: most
importantly, I'm not using CSS variables. I decided to instead dynamically add a dark
CSS class
to the <body>
element to enable the dark theme.
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
// Adds or remove the 'dark' class from document.body per localStorage and
// prefers-color-scheme. If |toggle| is truthy, toggles the current value and
// saves the updated value to localStorage.
function applyTheme(toggle) {
const hasStorage = typeof Storage !== 'undefined';
let dark = false;
if (toggle) {
dark = !document.body.classList.contains('dark');
if (hasStorage) localStorage.setItem('theme', dark ? 'dark' : 'light');
} else {
const saved = hasStorage ? localStorage.getItem('theme') : null;
dark = saved !== null ? saved === 'dark' : darkQuery.matches;
}
dark
? document.body.classList.add('dark')
: document.body.classList.remove('dark');
}
// Toggle the theme when an icon is clicked.
document.getElementById('dark-icon')
.addEventListener('click', () => applyTheme(true));
In the stylesheet, I can then style elements appropriately:
body {
color-scheme: light;
background-color: white;
color: black;
}
body.dark {
color-scheme: dark;
background-color: black;
color: white;
}
a {
color: navy;
}
body.dark a {
color: yellow;
}
(In actuality, I use Sass to make writing CSS less tedious.)
Inverting icon images
My website uses small black monochrome images as icons in various places. I was initially stumped about how
to swap in white versions of these images for the dark theme: I didn't want to use JavaScript to update all
of the <img>
elements' src
attributes when toggling the theme, and using
the background-image
CSS property (which can be overriden in the stylesheet) also seemed like
it would be a disaster.
I eventually realized that I could use the
filter CSS property to easily invert
the icons in my <header>
element from black to a brighter, almost-white color:
body.dark header img {
filter: invert(1) brightness(0.9);
}
Applying themes to iframes
My website uses iframes to
display subpages containing interactive graphs and maps (see e.g. glucometer.html
and pullups.html), and I wanted the framed pages to be styled in concordance with
the main page. To do this, I included the above applyTheme
code and the following in the framed
HTML documents:
applyTheme();
darkQuery.addEventListener('change', () => applyTheme());
window.addEventListener('storage', () => applyTheme());
This way, the framed page uses the same logic as its outer page to select the initial theme. Calling applyTheme()
in response to storage
events makes the framed page automatically update its theme when the
user toggles the theme on the outer page, and I'm able to style framed documents using the same body.dark
selector that I use for outer pages.
After doing this, I noticed that when using the dark theme, iframes were rendered as ugly white boxes before
they loaded rather than being transparent. I eventually ended up at
chromium issue 1150352, where I
learned that this is intentionally done in
cases where the color-scheme
CSS property of the <iframe>
element and of the
framed document's root element don't match. It wasn't straightforward for me for set color-scheme
on the root element, so I instead used this Stack Overflow
answer's approach of hardcoding the property on <iframe>
elements to override the
behavior:
iframe {
color-scheme: normal;
}
This post by Florens Verschelde goes into much more detail about the interaction between dark mode and transparent iframes.
Supporting dark mode in AMP pages
My website provides an AMP version of each page (as described in excruciating detail in the AMP conversion page).
For the most part, AMP discourages custom JavaScript (although I think it's now supported to some degree via the amp-script tag and actions and events).
I haven't been able to find much documentation about it, but as discussed in amphtml issue 20951, a toggleTheme action was added to support switching between the dark and light themes:
<amp-img role="button" on="tap:AMP.toggleTheme()" etc ></amp-img>
By default, AMP sets an amp-dark-mode
class on the <body>
element when the
dark theme should be used, but I used the data-prefers-dark-mode-class
attribute to tell it to
instead set the same dark
class that I use for non-AMP pages:
<body data-prefers-dark-mode-class="dark">
As a bonus, AMP appears to save the user's selection after they toggle dark mode on or off, so they'll see the same theme if they reload the page later.
I noticed that iframe-embedded documents were throwing errors when they tried to call my applyTheme
function:
Uncaught DOMException: Failed to read the 'localStorage' property from 'Window': The document is sandboxed and lacks the 'allow-same-origin' flag.
As described in my AMP conversion page, AMP doesn't permit setting
allow-same-origin
on iframes. Inspired by this
Stack Overflow answer, I added a check for document.domain
to the top of applyTheme
:
function applyTheme(toggle) {
// AMP iframes can't use allow-same-origin since they might be served from the
// cache. Check document.domain to determine if we're sandboxed, which
// prevents us from accessing localStorage: https://stackoverflow.com/a/34073811
//
// Just give up and use the light theme in this case, since we won't be able
// to tell if the user toggles the theme, and using the dark theme in an
// iframe while the rest of the page is using the light theme looks weird.
if (!document.domain) return;
const hasStorage = typeof Storage !== 'undefined';
...
This means that iframed documents always use the light theme when embedded in AMP pages, which isn't great, but I couldn't come up with any way to let them know about theme changes in the AMP page.
Styling embedded Google Maps
Some of my pages use the Google Maps JavaScript API. The default map style uses bright colors, but I was able to use the Google Maps Style Wizard to create a JSON style based on the "Night" theme that worked well with my site's dark theme. It was then straightforward to use the API to dynamically update the map's style to match the theme:
// Returns the 'styles' value for google.maps.MapOptions.
function getStyles() {
// Just use the default light style if the dark theme isn't being used.
if (!document.body.classList.contains('dark')) return undefined;
return [
{
elementType: 'geometry',
stylers: [{ color: '#242f3e' }],
},
{
elementType: 'labels.text.fill',
stylers: [{ color: '#746855' }],
},
// ...
];
}
// Sets the 'dark' class on the <body> element and updates the map's styles.
function updateStyle() {
applyTheme();
map.setOptions({ styles: getStyles() });
}
window.addEventListener('DOMContentLoaded', () => {
darkQuery.addEventListener('change', () => updateStyle());
window.addEventListener('storage', () => updateStyle());
// [omit lengthy Maps API initialization code]...
updateStyle();
});
Chroma syntax highlighting
I use the Chroma Go library to perform syntax highlighting on code snippets included in pages. The Chroma style gallery shows that many different light-on-dark styles are available, but I needed to find a way to automatically switch the Chroma style to match the page's theme.
Luckily, the Chroma HTML formatter's
WriteCSS
method can be used to emit a stylesheet containing the CSS classes for a style. I wrote a function that
can mangle the selectors to add a body.dark
prefix to the dark style:
import (
"github.com/alecthomas/chroma"
"github.com/alecthomas/chroma/formatters/html"
"github.com/alecthomas/chroma/styles"
)
var chromaFmt = html.New(html.WithClasses(true))
// getCodeCSS generates CSS class definitions for the named Chroma style.
// If selPrefix is non-empty (e.g. "body.dark "), it will be prefixed to each selector.
func getCodeCSS(style, selPrefix string) (string, error) {
cs := styles.Get(style)
if cs == nil {
return "", fmt.Errorf("couldn't find chroma style %q", style)
}
var b bytes.Buffer
if err := chromaFmt.WriteCSS(&b, cs); err != nil {
return "", err
}
s := b.String()
if selPrefix != "" {
// The unminified CSS consists of lines like
// "/* Background */ .chroma { color: #f8f8f2; ...".
s = strings.ReplaceAll(s, " .chroma ", selPrefix+" .chroma ")
}
return s, nil
}
I generate CSS for both light and dark styles, which I then include in the page's stylesheet:
lightCSS, _ := getCodeCSS("solarized-light", "")
darkCSS, _ := getCodeCSS("solarized-dark", "body.dark ")
The generated CSS isn't small (almost 5 KB minified), but it works.
- Edge added support in 2020. [return]