Guide to HTML dark mode responsive images

This article will mainly focus on the usage of images with different themes on a website, different image formats, and some ways to add fallback for broken images. While the given methods are not focused on reducing metrics such as First Contentful Paint (FCP), they will be helpful to smoot the user experience. If you want to read more about responsive images and better understand images in HTML in general, the MDN docs’ section on img has a lot of useful information, as well as links to picture docs and a guide on responsiveness. CSS-Tricks: A Guide to the Responsive Images Syntax in HTML is a great read too.

TL;DR: code snippet.

Image formats and themes

Supporting modern image formats

If you are using progressive image formats, consider using picture to provide different sources for your image: one being the preferred format, say, AVIF, and the other having better browser support, say JPEG:

<picture>
    <source type="image/avif" srcset="images/hero.avif">
    <img src="images/hero.jpeg" alt="Hero image" />
</picture>

The general rule is you should specify higher priority assets on top, so the default, or less-preferred image, is specified directly in img. The browser sees source with the specified type of image/avif and if the browser supports it, image from srcset attribute for the given source will be loaded. Note that you can specify any image in srcset attribute, for example SVG, and the browser will still load it. You might think of source’s attributes as conditions on which the browser should process content of the respective srcset value. The following markup will load images/hero.svg if the browser supports WEBP:

<picture>
    <source type="image/webp" srcset="images/hero.svg">
    <img src="images/hero.jpeg" alt="Hero image" />
</picture>

Supporting dark and light themes

There are a number of CSS media queries, and the one used for detecting the preferred user’s theme is prefers-color-scheme. Using this media query in your CSS code will automatically detect the user’s system theme and apply corresponding styles:

p {
    color: #0E0E0E;

    @media (prefers-color-scheme: dark) {
        color: #FAFAFA;
    }
}

The same applies to the source, if the parent element is picture, as it supports media queries inside media tag. You can use that to your advantage and load different images based on the user’s preferred theme. The following code will load images/hero_dark.webp if the user’s preferred theme is dark, and images/hero_light.webp otherwise (auto value for system theme settings generally resolves to light or dark depending on system time):

<picture>
    <source media="(prefers-color-scheme: dark)"
            type="image/webp"
            srcset="images/hero_dark.webp" />
    <img src="images/hero_light.webp" alt="Hero image" />
</picture>

Combining the two

We can combine these methods to serve the most suitable images. Don’t forget that the order of sources matters. Let’s write a piece of markup that will serve WEBP if possible and PNG images otherwise for both light and dark themes.

<picture>
    <source media="(prefer-color-scheme: dark)"
            type="image/webp"
            srcset="images/hero_dark.webp">
    <source type="image/webp"
            srcset="images/hero_light.webp">
    <source media="(prefer-color-scheme: dark)"
                type="image/png"
                srcset="images/hero_dark.png">
    <img src="images/hero_light.png" alt="Hero image" />
</picture>

I was somewhat cheating along those paragraphs by not specifying media query for the light theme. Why? Because that is something I consider the default behaviour or case. This might be useful if the device on which browser is running has no support for themes. If you are not specifying a media query for your default case, put that source after other cases. Just think about this as (nested) if/switch statements:

if (supports('webp')) {
    switch (theme) {
        case 'dark':
            return 'dark.webp';
        default:
            return 'light.webp';
    }
}
else if (supports('png')) {
    ...

Theme switching

Basics

Say, you want to give your users the ability to set their preferred theme for your website. You’ve done it; now your root element has that shiny class="dark-theme" or whatever, and you write your styles accordingly:

p { color: #0E0E0E; }
@media (prefers-color-scheme: light) { p { color: #0E0E0E; } }

:root.dark-theme p { color: #FAFAFA; }
@media (prefers-color-scheme: dark) { p { color: #FAFAFA; } }

Usage with images

Great! Is that all there is to it? Depends. If you want your users to explicitly set preferred themes, this won’t work, because those media queries only match system theme. Now, you have at least two options here.

All using JavaScript

For each of your used image formats, remember format support in priority order via fake img or using canvas. Store URLs for each image for each image format for each theme as data-* attributes. Dispatch a custom event on theme switching. Listen to that event on each of your images and choose the best one. That’s a lot of setup, isn’t it?

Avoiding JavaScript

This one is a little bit tricky. If your website uses a lot of images that vary for different themes (and I mean A LOT) it might be better to come up with some JavaScript code, as the following method will increase the HTML sent to the user, if you care about transferred bytes.

This no-JS approach that I’ve used is very simple to understand, works without any JavaScript code, and can be extended with responsive syntax of picture and source. This approach is not limited to images, and you’ve probably seen it being used.

display: none; is the perfect solution, IMO. Let’s consider three theme states: system, dark, and light, with light theme being the default. For light and dark themes, we have special utility classes for our root element: light-theme and dark-theme. We then write our CSS like this:

/* default style */
p { color: #0E0E0E; }

/* light theme styles */
:root.light-theme p { color: #0E0E0E; }
@media (prefers-color-scheme: light) {
    p { color: #0E0E0E; }
}

/* dark theme styles */
:root.dark-theme p { color: #FAFAFA; }
@media(prefers-color-scheme: dark) {
    p { color: #FAFAFA; }
}

We then can add some more utility classes or data-attributes to control the display of content:

/* hide content for system theme */
:root:not(.light-theme):not(.dark-theme) {
    [data-light-theme],
    [data-dark-theme] { display: none; }
}

/* hide content for light theme */
:root.light-theme {
    [data-system-theme],
    [data-dark-theme] { display: none; }
}

/* hide content for dark theme */
:root.dark-theme {
    [data-system-theme],
    [data-light-theme] { display: none; }
}

This way, we can hide any content in any theme. But here is a catch: since picture actually displays img, “hiding” source element will not have any effect — both of these pictures will display images/dark.webp:

<html class="light-theme">
<!-- ... -->
    <picture>
        <source type="image/webp" srcset="images/dark.webp" data-dark-theme>
        <source type="image/webp" srcset="images/light.webp">
        <img src="images/light.webp" alt="Example image" />
    </picture>

    <picture>
        <source type="image/webp" srcset="images/dark.webp" data-dark-theme>
        <img src="images/light.webp" alt="Example image" />
    </picture>
<!-- ... -->
</html>

But we can hide picture instead! Just add a class or data-attribute to your pictures for preferred themes:

<picture data-light-theme>
    <source type="image/webp" srcset="images/light.webp">
    <img src="images/light.png" alt="Example image" />
</picture>

<picture data-dark-theme>
    <source type="image/webp" srcset="images/dark.webp">
    <img src="images/dark.png" alt="Example image" />
</picture>

<picture data-system-theme>
    <source media="(prefers-color-scheme: dark)" type="image/webp" srcset="images/dark.webp">
    <source type="image/webp" srcset="images/light.webp">
    <source media="(prefers-color-scheme: dark)" type="image/png" srcset="images/dark.png">
    <img src="images/light.png" alt="Example image" />
</picture>

By now, you should be able to figure out what the output of given HTML is for different classes on html by yourself.

Preventing the loading of unused images

You could notice that with such an approach, browser still loads an image for each picture. This is a sad reality, in which browsers still load images that are hidden. Gladly, there is a way to prevent it. Sadly, Safari is late to the party. Throw loading="lazy" to all your imgs and voila — the browser loads just what is needed. The latest to implement support for loading attribute was Safari team, as it is supported only since “15.4 (Released 2022-03-15)“. If the user’s browser is somewhat modern, this method should not have a noticeable effect on performance except for some extra bytes transferred in HTML.

Prioritize asset loading

You can even go a greater length and not add loading="lazy" for the selected user’s theme. This will ensure that needed images are loaded as soon as possible.

<html class="light-theme">
    <!--...-->
    <picture data-light-theme>
        <source type="image/webp" srcset="images/light.webp">
        <img src="images/light.webp" alt="Hero image" :loading="theme() !== 'light' ? 'lazy' : null" />
    </picture>
    <picture data-dark-theme>
        <source type="image/webp" srcset="images/dark.webp">
        <img src="images/dark.webp" alt="Hero image" :loading="theme() !== 'dark' ? 'lazy' : null" />
    </picture>
    <!-- ... -->

Handling errors and displaying a fallback image

FWIW, I tried. As of publication date, I wasn’t able to display the fallback image in the given setup without JavaScript. Considering the following markup, for me, it was only natural to expect to see either image/light.webp or image/fallback.png when the user uses the system theme and sets theme preference to dark:

<picture data-system-theme>
    <source media="(prefers-color-scheme: dark)" type="image/webp" src="images/dark_broken.webp">
    <source type="image/webp" src="images/light.webp">
    <img src="images/fallback.png" alt="Fallback or light?" />
</picture>

But it just shows alt text. At least this throws an error, and we can leverage that with some JavaScript:

document.querySelectorAll('img[data-fallback-src]:not([data-fallback-src=""])')
    .forEach(img => img.onerror = () => {
        img.onerror = null;
        if (img.parentElement.nodeName === 'PICTURE') {
            while (img.previousElementSibling) img.previousElementSibling.remove();
        }
        img.src = img.dataset.fallbackSrc;
    });

Firstly, we select all img with data-fallback-src attribute that is not empty. For each of those images, we add onerror event handler, in which:

  1. The event handler is removed, so it won’t occur while the function is executing;
  2. If this img is inside picture, we must remove all sources, otherwise they will overwrite img’s src;
  3. We set img’s src to our fallback image.

There is also a wacky way with the use of object, but it doesn’t fit our requirements.

Conclusion

Use picture to your advantage: specify types, media queries. Dig into responsiveness if your templates require it. It is a very powerful tool. Add a little bit of CSS to have better control over themes. And add that little bit of JavaScript code: it won’t break the website and will improve UX.

$ cd ..