Michael Ti Dismatsek •

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 = undefined) {
    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 = undefined) {
    // 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 🌚.