I spent three hours last Tuesday staring at a massive styles.css file, manually typing out @media queries for six different background image resolutions. It broke my spirit. HTML gets the fancy <picture> tag, but when you need a hero section with a CSS background image, you’re suddenly stuck in 2015.
Anyway. I finally snapped and decided to automate the whole thing using the Eleventy Image plugin.
Look, serving a 2MB JPEG to a mobile device is a fireable offense at this point. We know we need AVIF and WebP formats. We know we need different sizes based on device pixel ratios. But doing that natively in CSS? A nightmare. You end up with a wall of image-set() functions and breakpoint logic that nobody wants to maintain.
And if you’re running Node.js 22.14.0 and 11ty, you probably already use @11ty/eleventy-img for your HTML templates. But generating a CSS file with it requires a slightly different mental model.
The JavaScript Template Approach
Instead of trying to force a shortcode into a Nunjucks file, I created a custom JavaScript template (backgrounds.11ty.js) that outputs pure CSS. This gives you full access to Node’s async capabilities right in the build step.
Here is the exact setup I’m using on my M3 MacBook Pro right now:
const Image = require("@11ty/eleventy-img");
module.exports = class {
data() {
return {
permalink: "/assets/css/hero-backgrounds.css",
eleventyExcludeFromCollections: true,
};
}
async render() {
const src = "./src/images/massive-hero.jpg";
let stats = await Image(src, {
widths: [400, 800, 1600, 2400],
formats: ["avif", "webp", "jpeg"],
outputDir: "./_site/img/",
urlPath: "/img/"
});
// Generate the CSS string
return
.hero-bg {
background-image: url('${stats.jpeg[0].url}');
}
@supports (background-image: image-set(url(''))) {
.hero-bg {
background-image: image-set(
url('${stats.avif[0].url}') type('image/avif'),
url('${stats.webp[0].url}') type('image/webp'),
url('${stats.jpeg[0].url}') type('image/jpeg')
);
}
}
@media (min-width: 768px) {
.hero-bg {
background-image: image-set(
url('${stats.avif[2].url}') type('image/avif'),
url('${stats.webp[2].url}') type('image/webp'),
url('${stats.jpeg[2].url}') type('image/jpeg')
);
}
}
;
}
};
The Async Race Condition Gotcha
Well, that’s not entirely accurate. The official docs don’t really emphasize this, but if you run the image generation asynchronously inside a CSS template without awaiting the metadata properly, 11ty will occasionally spit out an empty CSS file on the first build.
I wasted an hour trying to figure out why my styles were missing on Vercel but working locally. The fix is ensuring your await Image() call completes before you attempt to access the stats object in your template literal. If you try to map over the formats dynamically without resolving the promise first, you get undefined variables crashing your build.
Real Numbers: Was It Worth It?
I tested this refactor on a client project with a directory of 12 high-res hero images. Before automation, we had about 84KB of bloated CSS just handling manual background breakpoints, and we were only serving JPEGs because I was too lazy to manually convert everything to AVIF.
But after hooking up the 11ty image plugin to output AVIFs and a dynamic CSS file, our total CSS payload dropped to 14KB (since the plugin only generates exactly the CSS we need). More importantly, the actual image payload on mobile devices dropped by 68% because they were finally getting properly sized AVIFs instead of downscaled desktop JPEGs.
The build time did take a hit initially. Processing all those AVIFs pushed my local build from 3.2 seconds to about 18 seconds. However, once I configured the 11ty .cache folder to persist between runs, subsequent builds dropped back down to 3.5 seconds.
I’m never writing a CSS media query for a background image by hand again. If you’re still doing it manually, stop. The upfront cost of writing the JS template pays for itself the very next time you need to swap out a hero image.


