Responsive, Self-hosted Images for Your Eleventy Blog

  • self-hosting
  • docker
  • eleventy

While you can certainly host your image files with the Git repo your Eleventy site is checked into, or add them manually after building it, neither option is ideal if you want responsive images in multiple formats to save precious bandwidth.

GitHub imposes limits on the size of files you can commit, and when your repo reaches a certain size, GitHub will ask you nicely to reduce its size (or else…)

There’s Git LFS, but even then, having to check out several hundreds of megabytes of assets (or more) when you’re cloning your repo is equally unwieldy and the costs for storage and bandwidth can quickly add up as the repo grows.

Fortunately, Eleventy provides the @11ty/eleventy-img plugin, which is very capable when it comes to transformations and responsive images. It works not only with local files, but also with remote resources and includes them in the output during a build.

You could certainly host your images on an external site like Imgur and have @11ty/eleventy-img pull them in for processing to avoid having to put them in your Git repo. However, external hosting services may have their own restrictions on what you can upload to them (file size, resolution, formats). And with every internet company trying to jump on the “AI” bandwagon, no matter how small, no matter how unnecessary, I’d be very wary of trusting someone else with my blog’s images.

So self-hosting is pretty much the only other option. I found a repo on GitHub that lists a number of self-hosting services, among them a couple for image hosting. One of them, which I would like to cover in this post, is called PicoShare. It can be used to host not only images, but also videos, zip archives and much more. It just hosts the files, it doesn’t process them, so it’s perfect for running on that Raspberry Pi you’ve got lying around in a drawer somewhere.

Setting up PicoShare

Note

I’m assuming you already know how to use Docker.

PicoShare is single-user only, but for the purpose of having a super simple file hosting solution with an equally simple web UI, this is entirely sufficient. If you need to give other people access to it, you can do so with its “Guest Links” feature and they can upload something to it.

Here’s my docker-compose.yml I use to run PicoShare:

version: "3.2"

services:
  picoshare:
    image: mtlynch/picoshare
    container_name: picoshare
    environment:
      PORT: 4001
      PS_BEHIND_PROXY: true
      PS_SHARED_SECRET: SuperSecretPassNobodyWillEverKnow
    ports:
      - 4001:4001
    volumes:
      - picoshare:/data

volumes:
  picoshare:

The PicoShare README.md doesn’t use volumes in its compose file example, but it was the only way for me to work around weird permission issues with bind mounts on Podman. I’m also running it behind a reverse proxy, so I have the PS_BEHIND_PROXY environment variable set. This is so I can have a memorable host name as well as TLS termination, which spares me having to get a Let’s Encrypt certificate for every individual service I host.

So after a quick docker compose up -d PicoShare should be up and running in no time and visiting http://localhost:4001 should give your its greeting page.

PicoShare greeting page

From there you can log in with the passphrase you set in the docker-compose.yml and start uploading some files. By default, PicoShare sets an expiration period of 30 days on every upload. If you plan on using it for blog post images, that’s probably not what you want, so you may want to disable that in the settings.

Once you upload a file, PicoShare shows you a full link and a short link. You can use either to link to an image for linking an image on a website.

Responsive Images in Eleventy

In order to have responsive images in the blog at all, you have to customize the config and configure the image transform plugin. Install the @11ty/eleventy-img plugin via NPM and add it with the addPlugin() helper function with some options:

import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
    extensions: "html",
    formats: ["webp", "jpeg"],
    defaultAttributes: {
      loading: "lazy",
      decoding: "async",
    },
  });
}

The Eleventy image transform plugin is highly configurable. Below are the most common options, followed by more advanced options.

Basic options
extensions – Type: string – Detault: "html"

Comma separated list of which extensions to scan for <img> tags to transform into responsive <picture> tags.

widths – Type: Array<number | "auto" | null> – Default: [null]

A list of resolutions in which the images should be created. Their aspect ratio will be kept automatically. Including auto or null in the list will also include a converted version of the image in its original resolution.

Important: When including more than one resolution the sizes attribute becomes obligatory. See defaultAttributes below.

formats – Type: Array<string | "auto" | null> – Default: ['webp', 'jpeg']

A list of formats images should be converted into. Accepted formats are:

  • auto/null (same as input)
  • svg/svg+xml (only applies when the input is SVG)
  • jpeg/jpg
  • png
  • webp
  • avif
concurrency – Type: number– Default: 10

How many concurrent threads should be used for image transformation.

urlPath – Type: string – Default: "/img/"

A path that gets prefixed to the src attribute of <img> tags.

outputDir – Type: string – Default: "img/"

The output directory for transformed images, relative to the Eleventy output directory.

svgShortCircuit – Type: boolean | "size" – Default: false

Whether or not to skip raster formats for SVG. Only applies when svg is included in the formats array.

  • false (default): SVG images will be converted into raster images.
  • true: SVG images will not be converted into raster images, even if svg is part of the formats array of image types to be converted into. Useful when you can’t say for sure if the input will be raster or vector based and you want to always keep SVGs as vector based images.
  • size: SVG images will only be converted into raster images, if the resulting output file is smaller than the original SVG file.
svgAllowUpscale – Type: boolean – Default: true

Whether or not to upscale SVG images before they are converted into raster images.

svgCompressionSize – Type: string – Default: "" (empty string)

Set to br to report an SVG’s size as it would be after being compressed with Brotli.

defaultAttributes – Type: object – Default: undefined

HTML attributes to add to every processed image. Can be any valid HTML attribute for <img> or <source>.

For the most common use-cases, the default options get the job done pretty well. If you want to fine-tune the inner workings of the Eleventy image transform plugin even further, refer to the advanced options below:

Advanced options
sharpOptions – Type: sharp.SharpOptions – Default: See Sharp docs

An options object to pass to the Sharp constructor.

sharpWebpOptions – Type: sharp.WebpOptions – Default: See Sharp docs

Options for how WebP images should be processed by Sharp.

sharpPngOptions – Type: sharp.PngOptions – Default: See Sharp docs

Options for how PNG images should be processed by Sharp.

sharpJpegOptions – Type: sharp.JpegOptions – Default: See Sharp docs

Options for how JPEG images should be processed by Sharp.

sharpAvifOptions – Type: sharp.AvifOptions – Default: See Sharp docs

Options for how AVIF images should be processed by Sharp.

cacheOptions – Type: CacheOptions – Default: See below

Controls how external sources should be cached.

type – Type: string – Default: 'buffer'
The expected content type of the response when fetching the remote resource. Accepts buffer (binary data), json or text.
directory – Type: string – Default: ".cache"
Directory in which to store cached resources. Relative to the project directory.
fetchOptions – Type: RequestInit – Default: See MDN docs
Options for configuring the behavior of the JavaScript Fetch API used to fetch resources.
concurrency – Type: number – Default: 10
How many concurrent fetch operations are allowed.
duration – Type: string – Default: '1d'
Time that needs to pass before fetching an already fetched resource again.
removeUrlQueryParams – Type: boolean – Default: undefined
Whether or not to remove the query parameters from URLs cached by Eleventy Image.
dryRun – Type: boolean – Default: undefined
Only simulate doing something.
verbose – Type: boolean – Default: undefined
Control verbosity of the cache.
hashLength – Type: number – Default: 30
Truncates hash to this length.
filenameFormat – Type: function(options): string | null | undefined

A function to customize the file names of transformed images in the final output. Returns either string, null or undefined

options – Type: object
id – Type: string
Hash of the original image
src – Type: string
Original image path
width – Type: number
Current width in px
format – Type: string
Current file format
options – Type: ImageOptions
An Eleventy Image options object (yes, the one you’re reading right now)
urlFormat – Type: function(format, options): string

A function to control how the URLs will look like when processed by Eleventy Image. When setting this function, its parameters are obligatory. Useful when you already have your own image processing service and saving the images with the site isn’t necessary or desired. Returns a string (the final URL).

format – Type: object
hash – Type: string
hash of the remote image. Not included for statsOnly images.
src – Type: string
source file name of the remote image.
width – Type: number
output width the remote image should have.
format – Type: string
image format the remote image should have.
options – Type: ImageOptions
An Eleventy Image options object (yes, the one you’re reading right now)
statsOnly – Type: boolean – Default: undefined

Whether or not to process files. If true only returned stats of images, doesn’t read or write any files. Useful in combination with urlFormat().

useCache – Type: boolean – Default: true

Whether or not to use in-memory cache.

dryRun – Type: boolean – Default: undefined

Whether or not to write anything to disk. A buffer instance will still be returned.

hashLength – Type: number – Default: 10

The maximum length of file name hashes.

useCacheValidityInHash – Type: true – Default: true

Advanced

fixOrientation – Type: boolean – Default: false

Whether or not to rotate images to ensure correct orientation.

minimumThreshold – Type: number – Default: 1.25

Ensures original size is included if smaller than largest specified width by threshold amount.

After you’ve set up the plugin to your needs, whenever you’re using an <img> in your templates (or the equivalent syntax in Markdown: ![alt text](url)), the plugin will transform the referenced image according to your options and put a corresponding <picture> tag in the resulting output.

Tip

If you want a side by side comparison to decide how to fine-tune the image compressors, check out Squoosh.

Some recommendations for a config with some options set:

import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
    extensions: 'html',
    formats: ['avif', 'webp', 'auto'], // commonly supported image formats
    widths: [640, 1280, 1920, 3840, 'auto'], // image sizes, from mobile up to 4K
    sharpJpegOptions: { 
      mozjpeg: true,              // more efficient compression for JPEGs
      optimiseScans: true,        // progressive loading
      quality: 95                 // higher quality
    },
    sharpPngOptions: { 
      compressionLevel: 9         // max compression for PNGs
    },
    urlPath: '/img/',             // prefix for generated <img src>
    outputDir: './public/img/',   // put all transformed images in the same place
    defaultAttributes: {
      loading: 'lazy',            // only load images once they come into view
      decoding: 'async',          // render images after DOM content has rendered
      sizes: '100vw'              // when more than 1 value in widths, set this
    }
  });
}
Attention

When specifying multiple values for widths you will also have to specify a sizes option in defaultAttributes. From MDN:

If the srcset attribute uses width descriptors, the sizes attribute must also be present, or the srcset itself will be ignored.

The Eleventy Image docs don’t explicitly mention this[1].

Info

The order of formats corresponds to the order of the <source> elements in the final output. The browser picks the first format in the list it supports. If you want it to use AVIF before WebP, make sure the list starts with avif.

Now you can just point to any image, local or remote, and the image transform plugin will download it, convert and transform it and include all sizes and formats in the output.

Responsive CSS Background Images with image-set()

The docs advertise the image transform plugin to also handle CSS background-image but I don’t find it working. Neither by adding css to the extensions option, nor assigning background-image in the CSS for my site or directly in my base.njk template in a <style> tag. That’s not an issue, however, as I can just write my own shortcode to generate the CSS properties I need.

In the same vein as the <picture> element with multiple <source> tags, there’s a counterpart in CSS called image-set(). The idea is pretty similar, albeit a little more restricted.

The image-set() CSS function takes up to 3 arguments per entry:

  • a url() pointing to the image file
  • an optional type() indicating what format it is
  • an optional pixel density for which resolution to use
.class {
  background-image: image-set(
    url("/img/background-1x.avif") type("image/avif") 1x,
    url("/img/background-2x.avif") type("image/avif") 2x,
    url("/img/background-3x.avif") type("image/avif") 3x,

    url("/img/background-1x.webp") type("image/webp") 1x,
    url("/img/background-2x.webp") type("image/webp") 2x,
    url("/img/background-3x.webp") type("image/webp") 3x,

    url("/img/background-1x.jpg")  type("image/jpeg") 1x,
    url("/img/background-2x.jpg")  type("image/jpeg") 2x,
    url("/img/background-3x.jpg")  type("image/jpeg") 3x
  )
}

It sadly does not take widths like a <source>, only pixel densities, which is kinda limiting.

In order to make use of this and have it be dynamic, we need to write our own little shortcode.

First we’re importing the Image function from @11ty/eleventy-img in the eleventy.config.js, will do most of the heavy lifting for us:

import Image from '@11ty/eleventy-img';

Then we’ll add a new shortcode (call it bgimgset) and make it call an async function with a single argument of src:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    // shortcode inner workings here
  });
}

The function needs to be async because Image works asynchronously, as it can fetch remote resources. Doing it synchronously would block everything else from running until the resource has been fetched. We don’t want that.

Next, we’ll pass Image the path or URL our shortcode receives as the src argument, which can be a local path or a URL to an image, and hold onto the result:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    const imgset = await Image(src);
  });
}

If we want it to convert the image, we need to pass Image some options, too:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    const imgset = await Image(src, {
      widths: [1920, 2560, 3840],
      sharpJpegOptions: { mozjpeg: true, optimiseScans: true, quality: 95 },
      sharpPngOptions: { compressionLevel: 9 },
      formats: ['avif', 'webp', 'auto'],
      urlPath: '/img/',
      outputDir: './public/img/'
    });
  });
}

You might notice, these are the same options we also passed the image transform plugin earlier. That’s because they pretty much share the same functionality, we’re just calling Image manually here.

After that, imgset holds all the stats of all the variants of our image. The data structure looks something like this:

{
  avif: [
    {
      format: 'avif',
      width: 1920,
      height: 1765,
      url: '/img/Pcl6PSU1Rc-1920.avif',
      sourceType: 'image/avif',
      srcset: '/img/Pcl6PSU1Rc-1920.avif 1920w',
      filename: 'Pcl6PSU1Rc-1920.avif',
      outputPath: 'public/img/Pcl6PSU1Rc-1920.avif',
      size: 107616
    },
    {
      format: 'avif',
      width: 2560,
      height: 2353,
      url: '/img/Pcl6PSU1Rc-2560.avif',
      sourceType: 'image/avif',
      srcset: '/img/Pcl6PSU1Rc-2560.avif 2560w',
      filename: 'Pcl6PSU1Rc-2560.avif',
      outputPath: 'public/img/Pcl6PSU1Rc-2560.avif',
      size: 152680
    },
    {
      format: 'avif',
      width: 3840,
      height: 3530,
      url: '/img/Pcl6PSU1Rc-3840.avif',
      sourceType: 'image/avif',
      srcset: '/img/Pcl6PSU1Rc-3840.avif 3840w',
      filename: 'Pcl6PSU1Rc-3840.avif',
      outputPath: 'public/img/Pcl6PSU1Rc-3840.avif',
      size: 261934
    }
  ],
  webp: [
    {
      format: 'webp',
      width: 1920,
      height: 1765,
      url: '/img/Pcl6PSU1Rc-1920.webp',
      sourceType: 'image/webp',
      srcset: '/img/Pcl6PSU1Rc-1920.webp 1920w',
      filename: 'Pcl6PSU1Rc-1920.webp',
      outputPath: 'public/img/Pcl6PSU1Rc-1920.webp',
      size: 214036
    },
    {
      format: 'webp',
      width: 2560,
      height: 2353,
      url: '/img/Pcl6PSU1Rc-2560.webp',
      sourceType: 'image/webp',
      srcset: '/img/Pcl6PSU1Rc-2560.webp 2560w',
      filename: 'Pcl6PSU1Rc-2560.webp',
      outputPath: 'public/img/Pcl6PSU1Rc-2560.webp',
      size: 317192
    },
    {
      format: 'webp',
      width: 3840,
      height: 3530,
      url: '/img/Pcl6PSU1Rc-3840.webp',
      sourceType: 'image/webp',
      srcset: '/img/Pcl6PSU1Rc-3840.webp 3840w',
      filename: 'Pcl6PSU1Rc-3840.webp',
      outputPath: 'public/img/Pcl6PSU1Rc-3840.webp',
      size: 599626
    }
  ],
  jpeg: [
    {
      format: 'jpeg',
      width: 1920,
      height: 1765,
      url: '/img/Pcl6PSU1Rc-1920.jpeg',
      sourceType: 'image/jpeg',
      srcset: '/img/Pcl6PSU1Rc-1920.jpeg 1920w',
      filename: 'Pcl6PSU1Rc-1920.jpeg',
      outputPath: 'public/img/Pcl6PSU1Rc-1920.jpeg',
      size: 756970
    },
    {
      format: 'jpeg',
      width: 2560,
      height: 2353,
      url: '/img/Pcl6PSU1Rc-2560.jpeg',
      sourceType: 'image/jpeg',
      srcset: '/img/Pcl6PSU1Rc-2560.jpeg 2560w',
      filename: 'Pcl6PSU1Rc-2560.jpeg',
      outputPath: 'public/img/Pcl6PSU1Rc-2560.jpeg',
      size: 1281555
    },
    {
      format: 'jpeg',
      width: 3840,
      height: 3530,
      url: '/img/Pcl6PSU1Rc-3840.jpeg',
      sourceType: 'image/jpeg',
      srcset: '/img/Pcl6PSU1Rc-3840.jpeg 3840w',
      filename: 'Pcl6PSU1Rc-3840.jpeg',
      outputPath: 'public/img/Pcl6PSU1Rc-3840.jpeg',
      size: 2746900
    }
  ]
}

As you can see, the resulting object contains all the stats for our image in all the specified formats and resolutions. What we need to do now is iterate over each format and extract the url and sourceType for each individual variant of the image. This is easily achieved with Object.values() which will return the value of each key in the object in an array:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    const imgset = await Image(src, {
      widths: [1920, 2560, 3840],
      sharpJpegOptions: { mozjpeg: true, optimiseScans: true, quality: 95 },
      sharpPngOptions: { compressionLevel: 9 },
      formats: ['avif', 'webp', 'auto'],
      urlPath: '/img/',
      outputDir: './public/img/'
    });

    // all the values of each image format key
    const images = Object.values(imgset)
  });
}

That gives us a multi-dimensional array (shortened for clarity):

[
  [
    {
      format: 'avif',
      width: 1920,
      height: 1765,
      url: '/img/Pcl6PSU1Rc-1920.avif',
      sourceType: 'image/avif',
      srcset: '/img/Pcl6PSU1Rc-1920.avif 1920w',
      filename: 'Pcl6PSU1Rc-1920.avif',
      outputPath: 'public/img/Pcl6PSU1Rc-1920.avif',
      size: 107616
    },
    // ...
  ],
  [
    {
      format: 'webp',
      width: 1920,
      height: 1765,
      url: '/img/Pcl6PSU1Rc-1920.webp',
      sourceType: 'image/webp',
      srcset: '/img/Pcl6PSU1Rc-1920.webp 1920w',
      filename: 'Pcl6PSU1Rc-1920.webp',
      outputPath: 'public/img/Pcl6PSU1Rc-1920.webp',
      size: 214036
    },
    // ...
  ],
  [
    {
      format: 'jpeg',
      width: 1920,
      height: 1765,
      url: '/img/Pcl6PSU1Rc-1920.jpeg',
      sourceType: 'image/jpeg',
      srcset: '/img/Pcl6PSU1Rc-1920.jpeg 1920w',
      filename: 'Pcl6PSU1Rc-1920.jpeg',
      outputPath: 'public/img/Pcl6PSU1Rc-1920.jpeg',
      size: 756970
    },
    // ...
  ]
]

We can loop over this with nested Array.prototype.map() to get the data we need to build valid CSS that image-set() will accept:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    const imgset = await Image(src, {
      widths: [1920, 2560, 3840],
      sharpJpegOptions: { mozjpeg: true, optimiseScans: true, quality: 95 },
      sharpPngOptions: { compressionLevel: 9 },
      formats: ['avif', 'webp', 'auto'],
      urlPath: '/img/',
      outputDir: './public/img/'
    });

    const images = Object.values(imgset);

    // loop over every image format
    const cssImageSet = images.map(function (format) {

      // loop over every resolution of current format
      format.map(function (resolution, index) {

        // get the relevant data of the current resolution
        const { url, sourceType } = resolution;

        // return a string in the format CSS image-set() expects
        // e.g. `url(/img/Pcl6PSU1Rc-1920.avif) type('image/avif') 1x`
        return `url(${url}) type('${sourceType}') ${index + 1}x`;
      });
    });
  });
}

We can shorten this a bit by using arrow functions and destructuring the object immediately in the callback function’s arguments:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    const imgset = await Image(src, {
      widths: [1920, 2560, 3840],
      sharpJpegOptions: { mozjpeg: true, optimiseScans: true, quality: 95 },
      sharpPngOptions: { compressionLevel: 9 },
      formats: ['avif', 'webp', 'auto'],
      urlPath: '/img/',
      outputDir: './public/img/'
    });

    const images = Object.values(imgset);

    const cssImageSet = images.map((format) =>
      format.map(({ url, sourceType }, i) => 
        `url(${url}) type('${sourceType}') ${i + 1}x`
      )
    );
  });
}

Now we have a data structure that’s looking pretty close to what we need:

[
  [
    "url(/img/Pcl6PSU1Rc-1920.avif) type('image/avif') 1x",
    "url(/img/Pcl6PSU1Rc-2560.avif) type('image/avif') 2x",
    "url(/img/Pcl6PSU1Rc-3840.avif) type('image/avif') 3x"
  ],
  [
    "url(/img/Pcl6PSU1Rc-1920.webp) type('image/webp') 1x",
    "url(/img/Pcl6PSU1Rc-2560.webp) type('image/webp') 2x",
    "url(/img/Pcl6PSU1Rc-3840.webp) type('image/webp') 3x"
  ],
  [
    "url(/img/Pcl6PSU1Rc-1920.jpeg) type('image/jpeg') 1x",
    "url(/img/Pcl6PSU1Rc-2560.jpeg) type('image/jpeg') 2x",
    "url(/img/Pcl6PSU1Rc-3840.jpeg) type('image/jpeg') 3x"
  ]
]

Before we can return the result, however, we need to flatten the multi-dimensional array we’ve been working with so far:

import Image from '@11ty/eleventy-img';

export default async function (eleventyConfig) {
  eleventyConfig.addShortcode('bgimgset', async (src) => {
    const imgset = await Image(src, {
      widths: [1920, 2560, 3840],
      sharpJpegOptions: { mozjpeg: true, optimiseScans: true, quality: 95 },
      sharpPngOptions: { compressionLevel: 9 },
      formats: ['avif', 'webp', 'auto'],
      urlPath: '/img/',
      outputDir: './public/img/'
    });

    const images = Object.values(imgset);

    const cssImageSet = images.map((format) =>
      format.map(({ url, sourceType }, i) => 
        `url(${url}) type('${sourceType}') ${i + 1}x`
      )
    );

    return cssImageSet.flat();
  });
}

Which gives us the final data structure we actually want:

[
  "url(/img/Pcl6PSU1Rc-1920.avif) type('image/avif') 1x",
  "url(/img/Pcl6PSU1Rc-2560.avif) type('image/avif') 2x",
  "url(/img/Pcl6PSU1Rc-3840.avif) type('image/avif') 3x",
  "url(/img/Pcl6PSU1Rc-1920.webp) type('image/webp') 1x",
  "url(/img/Pcl6PSU1Rc-2560.webp) type('image/webp') 2x",
  "url(/img/Pcl6PSU1Rc-3840.webp) type('image/webp') 3x",
  "url(/img/Pcl6PSU1Rc-1920.jpeg) type('image/jpeg') 1x",
  "url(/img/Pcl6PSU1Rc-2560.jpeg) type('image/jpeg') 2x",
  "url(/img/Pcl6PSU1Rc-3840.jpeg) type('image/jpeg') 3x"
]

Now we’re ready to make use of our new shortcode to get an image-set() from a single image:

.class {
  background-image: image-set({% bgimgset "https://cdn.imagesfrom.space/coffee-nebula_8150x7493.jpg" %});
}

And get this:

.class {
  background-image: image-set(
    url(/img/Pcl6PSU1Rc-1920.avif) type('image/avif') 1x,
    url(/img/Pcl6PSU1Rc-2560.avif) type('image/avif') 2x,
    url(/img/Pcl6PSU1Rc-3840.avif) type('image/avif') 3x,

    url(/img/Pcl6PSU1Rc-1920.webp) type('image/webp') 1x,
    url(/img/Pcl6PSU1Rc-2560.webp) type('image/webp') 2x,
    url(/img/Pcl6PSU1Rc-3840.webp) type('image/webp') 3x,

    url(/img/Pcl6PSU1Rc-1920.jpeg) type('image/jpeg') 1x,
    url(/img/Pcl6PSU1Rc-2560.jpeg) type('image/jpeg') 2x,
    url(/img/Pcl6PSU1Rc-3840.jpeg) type('image/jpeg') 3x
  )
}

Now the browser can make smarter choices about which image to use for the background, giving visitors the best quality image for their device’s resolution and saving you both valuable bandwidth!


  1. It took me a while to figure out this error specifically:

    Error: Missing sizes attribute on eleventy-img shortcode from: (url)

    What mystified me about this error was that I wasn’t using the eleventy-img shortcode anywhere. At first, I thought it was complaining about some Nunjucks template, so I went and added the sizes attribute on there and was fine for a while. Then when I added an image to one of my posts for the first time the error returned.

    What’s not immediately self-apparent is, that it seems like the shortcode gets used internally somewhere in between the transformation from Markdown to the final static HTML output, where it fails an internal check. That check is entirely justified, since it would be even more confusing if the srcset would get ignored and the images would misbehave. However, since Markdown doesn’t offer any way to add arbitrary attributes to images linked in Markdown files (not natively at least), the only way to satisfy that check is to add the defaultAttributes option when setting up the plugin and include a sizes attribute there.

    The docs aren’t as explicit about that fact as I’d have liked. I had to once again scour the web and read about it in someone else’s blog post, a pattern that, much to my annoyance, bears repeating… ↩︎