With the prefers-color-scheme
CSS media query now in all major browsers, itâs easy to get started with automatic light and dark themes on the web.
Using the <picture>
element and media
attribute, we can take things a step further and serve different images based on a userâs system colour preference:
<picture>
<source media="(prefers-color-scheme: light)" srcset="light version" />
<source media="(prefers-color-scheme: dark)" srcset="dark version" />
<img src="light version fallback" alt="" />
</picture>
But as Rhys Lloyd points out, that falls short once youâve added a user-controlled switcher for your themes using JavaScript, like in this example:
The image doesnât change! Thatâs because the prefers-color-scheme
query doesnât know about our bespoke theme switching implementation. Nor can we use CSS to manipulate the <source>
elements in any meaningful way.
With a little extra JavaScript, weâll dynamically switch between light and dark sources and override the system setting â without changing any of our markup.
Tl;dr complete demo
How does it work?
1. The picture element
The HTML5 <picture>
element works by loading the first <source>
whose conditions are met. For example, given two sources âŚ
<source media="(prefers-color-scheme: light)" srcset="light version" />
<source media="(prefers-color-scheme: dark)" srcset="dark version" />
⌠when the system colour scheme is light, it only loads the first source. If the system setting is dark, it skips the first source and loads the second one.
Thatâs nice on its own, but we need a way to supersede both of these conditions in order to display the image for a user-controlled colour preference.
To override them, we could simply place a new <source>
on top of the others which has no media condition. It will always take precedence:
<source srcset="override version" />
<source media="(prefers-color-scheme: light)" srcset="light version" />
<source media="(prefers-color-scheme: dark)" srcset="dark version" />
2. Connect with JavaScript
With that bit of knowledge, we can now write some code to insert the âoverrideâ source for every <picture>
depending on which theme the user selects!
First, letâs set up a function that takes the desired colour scheme, âlightâ or âdarkâ, and finds all the sources that match it:
function setPicturesThemed(colorScheme) {
document.querySelectorAll(`picture > source[media*="(prefers-color-scheme: ${colorScheme})"]`);
}
This isnât as complicated as it looks! Weâre using an attribute selector to get an array of all the sources we want to display based on the input colorScheme
.
For each of the matching sources we will make a clone, remove its media
condition, and prepend it to the parent <picture>
element. Weâll also give it a custom data attribute for future reference. Here it goes:
document.querySelectorAll(...).forEach(el => {
const cloned = el.cloneNode();
cloned.removeAttribute('media');
cloned.setAttribute('data-cloned-theme', colorScheme);
el.parentNode.prepend(cloned);
});
Perfect! Letâs check out what we have so far by adding a couple of buttons:
3. Almost thereâŚ
Two things are missing:
- The ability to switch back to the default system colour scheme
- Cleaning up the cloned sources. Right now the
<picture>
gets filled up with repeated elements every time the function is called.
To switch back to the default colour scheme, we just need to remove all of the <source>
elements that we created. Thatâs where the data-cloned-theme
attribute we gave them comes in handy:
document.querySelectorAll("picture > source[data-cloned-theme]").forEach((el) => {
el.remove();
});
Putting that at the beginning of our main function will also take care of cleaning everything up each time it runs.
Finally, weâll use the default value of colorScheme = undefined
(or null) to do that removal and nothing else â and voilĂ !
Complete code and demo
function setPicturesThemed(colorScheme) {
// Clean up all existing picture sources that were cloned
document.querySelectorAll("picture > source[data-cloned-theme]").forEach((el) => {
el.remove();
});
if (colorScheme) {
// Find all picture sources with the desired colour scheme
document
.querySelectorAll(`picture > source[media*="(prefers-color-scheme: ${colorScheme})"]`)
.forEach((el) => {
// 1. Clone the given <source>
// 2. Remove the media attribute so the new <source> is unconditional
// 3. Add a "data-cloned-theme" attribute to it for future reference / removal
// 4. Prepend the new <source> to the parent <picture> so it takes precedence
const cloned = el.cloneNode();
cloned.removeAttribute("media");
cloned.setAttribute("data-cloned-theme", colorScheme);
el.parentNode.prepend(cloned);
});
}
}
Caveats
This method doesnât currently account for sources with multiple media conditions, i.e. <source media="(prefers-color-scheme: dark) and (max-width: 900px)">
. To specify sizes, you may use srcset instead.
If thatâs a deal-breaker, you could amend the script to do some fancy string replacement instead of removing the whole media
attribute at the cloning step.
As for browser support: tl;dr all the modern ones including Edge 17+. This could be expanded lots by using ES5 syntax and a polyfill for ParentNode.prepend().
Night owls rejoice! đŚ
In just 13 lines of JavaScript, we wrote a function to override the preferred colour scheme for all of the native automatic light and dark mode pictures on a page.
Integrate this with the manual theme switcher on your website or app to always serve the right images â day đ and night đ.