Equal-height image layouts seem simple until you try to build one that's truly responsive. This tutorial walks through my solution using flexbox aspect ratios, the Eleventy Image plugin, and a Nunjucks shortcode.
A while back I came across an unexpectedly challenging image layout issue in CSS. I wanted to create a fluid, flexible, responsive image row where:
- The images display in a single horizontal row without wrapping or overflowing,
- The entire row width is fluid; it expands or shrinks to fill the width of its parent container,
- Images with various aspect ratios are scaled proportionally to each other and together take up the full width of the row, and
- All the images display at the same height, with their widths adjusting to maintain the aspect ratios.
Like this:



I thought this would be a cinch with CSS right out of the (flex)box, but after fiddling with combinations of flex
and object-fit
values, I realized it was less intuitive than I assumed.
Here's a Codepen of the closest I got to a solution. In this example, the images maintain their aspect ratios, but because I set an explicit height
on each image, they don't fluidly expand to fill the row container.
So what's missing? It involves aspect ratios, of course!
The solution: using aspect ratios as flex values
Eventually, I found this Codepen by Pat McKenna demonstrating a technique by Kartik Prabhu that was the key to solving this puzzle. (The concept is also explained well by Oliver Pattison in their article.)
The key is using each image's aspect ratio as its flex
value:
.fluid-row > img {
flex: calc(width / height);
}
Here's a basic implementation:
<figure class="fluid-row">
<img
src="landscape.jpg"
alt="Landscape image"
style="--aspect-ratio: 1.5;"
/>
<img
src="portrait.jpg"
alt="Portrait image"
style="--aspect-ratio: 0.8;"
/>
<!-- more images... -->
</figure>
.fluid-row {
display: flex;
}
.fluid-row > img {
width: 100%;
flex: var(--aspect-ratio);
}
See Equal-height flexible image row 2: solution on Codepen.
How the CSS works
Why/how does this work? Lemme review some flexbox basics. The flex
property controls how much an item will grow or shrink relative to other items. By setting it to the aspect ratio (width divided by height in this case), I'm telling the browser to allocate space proportionally to the width the image needs to maintain its aspect ratio at a consistent height.
For example:
- A square image (1:1 ratio) would get a flex value of 1.
- A landscape image (e.g., 16:9 ratio) would get a flex value of 1.78.
- A portrait image (e.g., 3:4 ratio) would get a flex value of 0.75.
What's happening with the rule flex: var(--aspect-ratio)
? Setting the shorthand flex
rule with a single value expands it to this:
flex-grow: [aspect-ratio];
flex-shrink: [aspect-ratio];
flex-basis: 0%;
Here's a summary of what each rule does:
flex-basis: 0%
tells the browser to start from zero width; don't consider the content or its intrinsic size.flex-grow: [aspect-ratio]
tells the browser to grow this item proportionally to its aspect ratio. If one item has an aspect ratio of 2, and another has 1, the first one will take up twice as much horizontal space.flex-shrink: [aspect-ratio]
- sinceflex-basis
is 0% and the container isn't overflowing, this rule doesn't actually do anything in this case.
Making it responsive with lazysizes
Okay, so this is nifty, but how the heck do I make it responsive?
Using srcset
and sizes
lets the browser choose the appropriate image size, but I need to know approximately what size the image will display at ahead of time so that I can set sizes
appropriately.
With the flexbox technique I'm using, an image's width is dependent on the size of its neighbors, so I can't predict if a given image will be 50% of the viewport, or 10%, or any other value.
This is where the lazysizes JavaScript library comes to the rescue. It offers a handy feature: data-sizes="auto"
.
After generating a few different sizes for each image and adding them to the data-srcset
attribute, lazysizes can calculate the sizes
attribute based on the current display width of the image. The browser can then select the best image from the srcset
. Woohoo!
Here's a basic implementation with lazysizes:
<div class="fluid-row">
<img
data-srcset="image-300.jpg 300w, image-600.jpg 600w, image-900.jpg 900w"
data-sizes="auto"
class="lazyload"
style="--aspect-ratio: 1.5;"
src="image-tiny.jpg"
alt="My image"
/>
<!-- more images... -->
</div>
Automating with Eleventy and Nunjucks
Sweet. Now, another issue: there is no way I am going to manually calculate and hardcode the aspect ratio for each image. Let's put this all together and automate it with Eleventy!
We will create a Nunjucks shortcode that:
- Accepts any number of images and their
alt
text. - Optionally accepts a caption for the containing
figure
element. - Uses the Eleventy Image plugin to:
- Get each image's dimensions,
- Calculate the aspect ratio,
- Generate multiple sizes of each image, and
- Create the proper
srcset
values.
The shortcode looks like this:
{% imageRow [
{ src: "one.jpg", alt: "Alt text" },
{ src: "two.jpg", alt: "Alt text" },
{ src: "three.jpg", alt: "Alt text" }
], "Optional caption." %}
Building the shortcode
Here's what I have in my .eleventy.js
file:
eleventyConfig.addNunjucksAsyncShortcode(
"imageRow",
async function (images, caption = "") {
const srcDir = "src/images/";
const outputDir = "dist/images/";
const imgUrlPath = "/images/";
try {
const imageData = await Promise.all(
images.map(async (image) => {
const fullImagePath = `${srcDir}${image.src}`;
const metadata = await Image(fullImagePath, {
widths: [300, 600, 900, 1200],
formats: ["jpeg"],
outputDir: outputDir,
urlPath: imgUrlPath,
filenameFormat: (id, src, width, format) => {
const filename = path.basename(src, path.extname(src));
return `${filename}-${width}w.${format}`;
},
});
const data = metadata.jpeg;
const largestImage = data[data.length - 1];
return {
srcset: data
.map((entry) => `${entry.url} ${entry.width}w`)
.join(", "),
placeholder: data[0].url,
aspectRatio: largestImage.width / largestImage.height,
alt: image.alt || "",
};
})
);
const captionHtml = caption
? `<figcaption class="text-small">${caption}</figcaption>`
: "";
return `<figure><div class="image-row">
${imageData
.map(
(img) =>
`<img src="${img.placeholder}"
data-srcset="${img.srcset}"
data-sizes="auto"
decoding="async"
class="lazyload"
style="--aspect-ratio: ${img.aspectRatio}"
loading="lazy"
alt="${img.alt}">`
)
.join("")}
</div>
${captionHtml}
</figure>`;
} catch (error) {
console.error("Error processing image row: ", error);
return `<div class="error">Image could not be displayed.</div>`;
}
}
);
Let's walk through this bit by bit.
Include plugins
const Image = require("@11ty/eleventy-img");
const path = require("path");
Include the Eleventy Image plugin and path
(this is available by default through Node; it provides utilities for working with file and directory paths). Put these two lines at the very top of .eleventy.js
.
Define the shortcode and path variables
eleventyConfig.addNunjucksAsyncShortcode(
"imageRow",
async function (images, caption = "") {
const srcDir = "src/images/";
const outputDir = "dist/images/";
const imgUrlPath = "/images/";
This registers the shortcode for use in Nunjucks templates. The parameters accept an array of image objects (each of these will contain a src
and alt
property), and an optional string to use for the figcaption
on the entire row.
I'm also declaring variables to define where my source images are stored, where processed images will be saved, and the URL path to use in the generated HTML. You'll want to adjust these to match your project's directory structure.
Process each image asynchronously
const imageData = await Promise.all(
images.map(async (image) => {
const fullImagePath = `${srcDir}${image.src}`;
For each image, we construct the full path to the source file.
Use Eleventy Image plugin to process the images
const metadata = await Image(fullImagePath, {
widths: [300, 600, 900, 1200],
formats: ["jpeg"],
outputDir: outputDir,
urlPath: imgUrlPath,
filenameFormat: (id, src, width, format) => {
const filename = path.basename(src, path.extname(src));
return `${filename}-${width}w.${format}`;
},
});
Here, we choose the different pixel widths and formats to generate for each image, set the paths, and rename them in a human-readable filename format.
Collect image metadata
const data = metadata.jpeg;
const largestImage = data[data.length - 1];
return {
srcset: data.map((entry) => `${entry.url} ${entry.width}w`).join(", "),
placeholder: data[0].url,
aspectRatio: largestImage.width / largestImage.height,
alt: image.alt || "",
};
This constructs the srcset
, uses the dimensions of the largest source image to calculate the aspect ratio, and sets the smallest image as our placeholder.
Build the HTML output
const captionHtml = caption
? `<figcaption class="text-small">${caption}</figcaption>`
: "";
return `<figure><div class="image-row">
${imageData
.map(
(img) =>
`<img src="${img.placeholder}"
data-srcset="${img.srcset}"
data-sizes="auto"
decoding="async"
class="lazyload"
style="--aspect-ratio: ${img.aspectRatio}"
loading="lazy"
alt="${img.alt}">`
)
.join("")}
</div>
${captionHtml}
</figure>`;
Construct the HTML, including the attributes for lazysizes.
Handle errors
} catch (error) {
console.error("Error processing image row: ", error);
return `<div class="error">Image could not be displayed.</div>`;
}
Return an error in the console and HTML if we run into any issues.
Wrapping up
And that's it! You now have a flexible, responsive image row component that automatically calculates aspect ratios and generates optimized images. The shortcode handles all the heavy lifting - just pass in your images and an optional caption. Good times.
You can view the code on GitHub if you want to adapt it for your own project!