---
title: Responsive, Self-hosted Images for Your Eleventy Blog
image:
  src: https://img.sebin-nyshkim.net/i/f33879d8-3f98-4c8e-a1b3-08edf6f174ac
  alt: Close-up of SVG code on a computer screen
  credit: Photo by Florian Olivo on Unsplash
  width: 1200
  height: 630
  type: 'image/png'
tags: ["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.

<!-- more -->

GitHub imposes [limits](https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-large-files-on-github) 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](https://github.com/awesome-selfhosted/awesome-selfhosted#photo-and-video-galleries) 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

> [!info] Note
> I'm assuming you already know how to use Docker.

[PicoShare](https://github.com/mtlynch/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:

```yaml
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](https://img.sebin-nyshkim.net/i/6df54a9e-e42f-4c6d-bd2d-105ff4e1cc16)

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:

```js
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](https://www.brotli.org/).

`defaultAttributes` – Type: `object` – Default: `undefined`
: HTML attributes to add to every processed image. Can be any valid HTML attribute for [`<img>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attributes) or [`<source>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source#attributes).
+++

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](https://sharp.pixelplumbing.com/api-constructor)
: An options object to pass to the Sharp constructor.

`sharpWebpOptions` – Type: `sharp.WebpOptions` – Default: See [Sharp docs](https://sharp.pixelplumbing.com/api-output#webp)
: Options for how WebP images should be processed by Sharp.

`sharpPngOptions` – Type: `sharp.PngOptions` – Default: See [Sharp docs](https://sharp.pixelplumbing.com/api-output#png)
: Options for how PNG images should be processed by Sharp.

`sharpJpegOptions` – Type: `sharp.JpegOptions` – Default: See [Sharp docs](https://sharp.pixelplumbing.com/api-output#jpeg)
: Options for how JPEG images should be processed by Sharp.

`sharpAvifOptions` – Type: `sharp.AvifOptions` – Default: See [Sharp docs](https://sharp.pixelplumbing.com/api-output#avif)
: 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](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)
  : 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] Tip
> If you want a side by side comparison to decide how to fine-tune the image compressors, check out [Squoosh](https://squoosh.app/).

Some recommendations for a config with some options set:

```js
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] Attention
> When specifying multiple values for `widths` you will also have to specify a `sizes` option in `defaultAttributes`. From [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img):
> > 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[^docs].

> [!info] 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()`](https://developer.mozilla.org/en-US/docs/Web/CSS/image/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

```css
.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:

```js
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`:

```js
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:

```js
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:

```js
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:

```js
{
  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
    }
  ]
}
```

<!-- We need to transform this data structure a bit for our needs. What we need to do is loop over every format and get the `url` and `sourceType` for each image. -->

<!-- We can loop over an object's values with the aptly named `Object.values()`, which will give us an array containing all the values of the object we pass it. The value of each key is an array itself, containing objects with the actual data we're interested in. We end up with arrays inside an array, which is a bit messy to work with. We can flatten this structure with `Array.prototype.flat()`: -->

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:

```js
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):

```js
[
  [
    {
      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()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map) to get the data we need to build valid CSS that `image-set()` will accept:

```js
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:

```js
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:

```js
[
  [
    "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:

```js
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:

```js
[
  "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:

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

And get this:

```css
.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!

*[LFS]: Large File Storage

[^docs]: 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](https://www.npmjs.com/package/markdown-it-attrs) 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](https://www.aleksandrhovhannisyan.com/blog/eleventy-image-transform/#configuration-api), a pattern that, much to my annoyance, bears repeating…
