Skip to content
jon@jonwebb-dev : ~ $

Responsive images with Eleventy

A responsive image generated with the Eleventy Image plugin
A responsive image generated with the Eleventy Image plugin

Images are one of the greatest contributors to web page bloat. When designing this site, I wanted to ensure that images were served in a responsible way.

I'm also lazy, and don't want to manually transform an image every time I add it to a blog post.

Fortunately, Eleventy (the excellent static site generator that builds this site) offers a powerful Image plugin that can handle all of these transformations at build-time.

Responsive images

A detailed summary of responsive image strategies in HTML can be found on the MDN Web Docs.

Briefly, we are going to be generating markup that looks like this:

<picture>
<source
type="image/webp"
srcset="/a-600.webp 600w, /a-1280.webp 1280w"
sizes="100vw"
/>

<source
type="image/jpeg"
srcset="/a-600.jpeg 600w, /a-1280.jpeg 1280w"
sizes="100vw"
/>

<img
src="/a-400.jpeg"
alt="Generated by the Eleventy Image plugin"
loading="lazy"
decoding="async"
width="1280"
height="720"
/>

</picture>

From this markup, the browser is instructed to:

Adding Eleventy Image

I've added a Nunjucks async shortcode called image that accepts two arguments:

{% image "a.jpg", "Generated by the Eleventy Image plugin" %}

Here's the implementation in .eleventy.js (the highlighted lines should be updated to match your project's directory structure):

const Image = require("@11ty/eleventy-img");

const widths = [600, 1280];
const formats = ["webp", "jpeg"];
const sizes = "100vw";

module.exports = (config) => {
const imageShortcode = async (src, alt) => {
if (alt === undefined)
throw new Error(`Missing "alt" on responsive image from: ${src}`);

const srcPath = path.join(
"src/_assets/images", // image asset source directory
src
);
const imgDir = path.parse(src).dir;

const metadata = await Image(srcPath, {
widths,
formats,
outputDir: path.join(
"_site/images", // output directory (relative to the project root)
imgDir
),
urlPath:
"/images" + // output directory (relative to the site HTML files)
imgDir,
});

return Image.generateHTML(metadata, {
alt,
sizes,
loading: "lazy",
decoding: "async",
});
};

config.addNunjucksAsyncShortcode("image", imageShortcode);
};

Within the imageShortcode function:

Caching remote images

The Image plugin can also cache and transform remote images at build-time, which means your site won't have to rely on external resources.

The shortcode will be updated so that it can accept a URL as the first argument:

{% image "https://picsum.photos/2400/1644", "A transformed remote image" %}

This utility function checks whether a string is an appropriate URL:

const isUrl = (str) => {
try {
return ["http:", "https:"].includes(new URL(str).protocol);
} catch {
return false;
}
};

If the src argument is a URL, we need to pass it to the Image plugin directly. I've also chosen to output all remote images to a subdirectory called remote:

const imageShortcode = async (src, alt) => {
if (alt === undefined)
throw new Error(`Missing "alt" on responsive image from: ${src}`);

const srcPath = path.join(
"src/_assets/images", // image asset source directory
src
);
const imgDir = path.parse(src).dir;
const srcPath = isUrl(src) ? src : path.join("src/_assets/images", src);
const imgDir = isUrl(src) ? "/remote" : path.parse(src).dir;

const metadata = await Image(srcPath, {
widths,
formats,
outputDir: path.join(
"_site/images",
imgDir
),
urlPath:
"/images" +
imgDir,
});

return Image.generateHTML(metadata, {
alt,
sizes,
loading: "lazy",
decoding: "async",
});
};

It's also important to add .cache to your .gitignore file, since that is where the Image plugin will store cached image data:

node_modules
_site
.cache
.DS_Store

Even more

For this site, I automatically wrap images in a <figure> element and and use the alt text as a caption:

const imageShortcode = async (src, alt) => {
if (alt === undefined)
throw new Error(`Missing "alt" on responsive image from: ${src}`);

const srcPath = path.join(
"src/_assets/images", // image asset source directory
src
);
const imgDir = path.parse(src).dir;
const srcPath = isUrl(src) ? src : path.join("src/_assets/images", src);
const imgDir = isUrl(src) ? "/remote" : path.parse(src).dir;

const metadata = await Image(srcPath, {
widths,
formats,
outputDir: path.join("_site/images", imgDir),
urlPath: "/images" + imgDir,
});

const markup = Image.generateHTML(metadata, {
alt,
sizes,
loading: "lazy",
decoding: "async",
});

return `<figure>${markup}<figcaption>${alt}</figcaption></figure>`;
};

Further reading


  1. The webp format is supported for ~94% of users as of January, 2022. ↩︎

  2. The srcset and sizes attributes are supported for ~95% of users as of January, 2022. ↩︎

  3. The decoding=async attribute for images is supported for ~87% of users as of January, 2022. ↩︎

  4. The loading=lazy attribute for images is supported for ~73% of users as of January, 2022. ↩︎