{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiboyc2apwtef4fqi3nwoftfvhpknqp5j6pjjld6lnpbzmvkewem4e",
"uri": "at://did:plc:fsiitr3vjhfv3vabubdisugo/app.bsky.feed.post/3lvvtwddlfg62"
},
"path": "/blog/proportional-equal-height-image-row-css-11ty-nunjucks/",
"publishedAt": "2026-05-10T08:05:42.836Z",
"site": "https://jeremyrobertjones.com",
"tags": [
"Here's a Codepen of the closest I got to a solution.",
"Codepen by Pat McKenna",
"technique by Kartik Prabhu",
"Oliver Pattison in their article",
"Equal-height flexible image row 2: solution",
"lazysizes JavaScript library",
"view the code on GitHub",
"@11ty"
],
"textContent": "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:\n\n 1. The images display in a single horizontal row without wrapping or overflowing,\n 2. The entire row width is fluid; it expands or shrinks to fill the width of its parent container,\n 3. Images with various aspect ratios are scaled proportionally to each other and together take up the full width of the row, and\n 4. All the images display at the same height, with their widths adjusting to maintain the aspect ratios.\n\n\n\nLike this:\n\nAdjust the width to see these images fit together nicely.\n\nI 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.\n\nHere'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.\n\nSo what's missing? It involves aspect ratios, of course!\n\n## The solution: using aspect ratios as flex values\n\nEventually, 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.)\n\nThe key is **using each image's aspect ratio as its parent container’s`flex` value**:\n\n\n .fluid-row > img {\n flex: calc(width / height);\n }\n\nHere's a basic implementation:\n\n\n <figure class=\"fluid-row\">\n <div class=\"fluid-row__item\" style=\"--aspect-ratio: 1.5;\">\n <img src=\"landscape.jpg\" alt=\"Landscape image\" />\n </div>\n <div class=\"fluid-row__item\" style=\"--aspect-ratio: 0.8;\">\n <img src=\"portrait.jpg\" alt=\"Portrait image\" />\n </div>\n <!-- more images... -->\n </figure>\n\n\n .fluid-row {\n display: flex;\n }\n\n .fluid-row__item {\n flex: var(--aspect-ratio);\n }\n\n .fluid-row__item > img {\n width: 100%;\n }\n\nSee Equal-height flexible image row 2: solution on Codepen.\n\n**Whoops:** My original version of this code did not include the `div.fluid-row__item` elements around each image. This worked in Firefox, but Chromium seems to let `img { width: 100%; }` override the `flex` value. Wrapping each image in a container and setting `flex` there takes care of this.\n\n## How the CSS works\n\nWhy/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.\n\nFor example:\n\n * A square image (1:1 ratio) would get a flex value of 1.\n * A landscape image (e.g., 16:9 ratio) would get a flex value of 1.78.\n * A portrait image (e.g., 3:4 ratio) would get a flex value of 0.75.\n\n\n\nWhat's happening with the rule `flex: var(--aspect-ratio)`? Setting the shorthand `flex` rule with a single value expands it to this:\n\n\n flex-grow: [aspect-ratio];\n flex-shrink: [aspect-ratio];\n flex-basis: 0%;\n\nHere's a summary of what each rule does:\n\n * `flex-basis: 0%` tells the browser to start from zero width; don't consider the content or its intrinsic size.\n * `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.\n * `flex-shrink: [aspect-ratio]` - since `flex-basis` is 0% and the container isn't overflowing, this rule doesn't actually do anything in this case.\n\n\n\n## Making it responsive with lazysizes\n\nOkay, so this is nifty, but how the heck do I make it responsive?\n\nUsing `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.\n\nWith 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.\n\nThis is where the lazysizes JavaScript library comes to the rescue. It offers a handy feature: `data-sizes=\"auto\"`.\n\nAfter 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!\n\nHere's a basic implementation with lazysizes:\n\n\n <div class=\"fluid-row\">\n <div class=\"fluid-row__item\" style=\"--aspect-ratio: 1.5;\">\n <img\n data-srcset=\"image-300.jpg 300w, image-600.jpg 600w, image-900.jpg 900w\"\n data-sizes=\"auto\"\n class=\"lazyload\"\n src=\"image-tiny.jpg\"\n alt=\"My image\"\n />\n </div>\n <!-- more images... -->\n </div>\n\n## Automating with Eleventy and Nunjucks\n\nSweet. 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!\n\nWe will create a Nunjucks shortcode that:\n\n 1. Accepts any number of images and their `alt` text.\n 2. Optionally accepts a caption for the containing `figure` element.\n 3. Uses the Eleventy Image plugin to:\n * Get each image's dimensions,\n * Calculate the aspect ratio,\n * Generate multiple sizes of each image, and\n * Create the proper `srcset` values.\n\n\n\nThe shortcode looks like this:\n\n\n {% imageRow [\n { src: \"one.jpg\", alt: \"Alt text\" },\n { src: \"two.jpg\", alt: \"Alt text\" },\n { src: \"three.jpg\", alt: \"Alt text\" }\n ], \"Optional caption.\" %}\n\n## Building the shortcode\n\nHere’s the full code (I put it in `src/shortcodes/imageRow.js` and import that into `.eleventy.js`).\n\n\n const path = require('path');\n const Image = require('@11ty/eleventy-img');\n\n module.exports = async function imageRow(images, caption = '') {\n const srcDir = 'src/images/';\n const outputDir = 'dist/images/';\n const imgUrlPath = '/images/';\n\n try {\n const imageData = await Promise.all(\n images.map(async (image) => {\n const fullImagePath = `${srcDir}${image.src}`;\n\n const metadata = await Image(fullImagePath, {\n widths: [300, 600, 900, 1200],\n formats: ['jpeg'],\n outputDir: outputDir,\n urlPath: imgUrlPath,\n filenameFormat: (id, src, width, format) => {\n const filename = path.basename(src, path.extname(src));\n return `${filename}-${width}w.${format}`;\n },\n });\n\n const data = metadata.jpeg;\n const largestImage = data[data.length - 1];\n return {\n srcset: data\n .map((entry) => `${entry.url} ${entry.width}w`)\n .join(', '),\n placeholder: data[0].url,\n aspectRatio: largestImage.width / largestImage.height,\n alt: image.alt || '',\n };\n })\n );\n\n const captionHtml = caption\n ? `<figcaption class=\"text-small\">${caption}</figcaption>`\n : '';\n\n return `<figure><div class=\"image-row\">\n ${imageData\n .map(\n (img) =>\n `<div class=\"image-row__item\" style=\"--aspect-ratio: ${img.aspectRatio}\">\n <img src=\"${img.placeholder}\"\n data-srcset=\"${img.srcset}\"\n data-sizes=\"auto\"\n decoding=\"async\"\n class=\"lazyload\"\n loading=\"lazy\"\n alt=\"${img.alt}\">\n </div>`\n )\n .join('')}\n </div>\n ${captionHtml}\n </figure>`;\n } catch (error) {\n console.error('Error processing image row: ', error);\n return `<div class=\"error\">Image could not be displayed.</div>`;\n }\n };\n\nLet's walk through this bit by bit.\n\n### Include plugins\n\n\n const Image = require('@11ty/eleventy-img');\n const path = require('path');\n\nInclude the Eleventy Image plugin and `path` (this is available by default through Node; it provides utilities for working with file and directory paths).\n\n### Define the shortcode and path variables\n\n\n module.exports = async function imageRow(images, caption = '') {\n const srcDir = 'src/images/';\n const outputDir = 'dist/images/';\n const imgUrlPath = '/images/';\n\nThe 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.\n\nI'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.\n\n### Process each image asynchronously\n\n\n try {\n const imageData = await Promise.all(\n images.map(async (image) => {\n const fullImagePath = `${srcDir}${image.src}`;\n\nFor each image, we construct the full path to the source file.\n\n### Use Eleventy Image plugin to process the images\n\n\n const metadata = await Image(fullImagePath, {\n widths: [300, 600, 900, 1200],\n formats: ['jpeg'],\n outputDir: outputDir,\n urlPath: imgUrlPath,\n filenameFormat: (id, src, width, format) => {\n const filename = path.basename(src, path.extname(src));\n return `${filename}-${width}w.${format}`;\n },\n });\n\nHere, 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.\n\n### Collect image metadata\n\n\n const data = metadata.jpeg;\n const largestImage = data[data.length - 1];\n return {\n srcset: data.map((entry) => `${entry.url} ${entry.width}w`).join(', '),\n placeholder: data[0].url,\n aspectRatio: largestImage.width / largestImage.height,\n alt: image.alt || '',\n };\n\nThis constructs the `srcset`, uses the dimensions of the largest source image to calculate the aspect ratio, and sets the smallest image as our placeholder.\n\n### Build the HTML output\n\n\n const captionHtml = caption\n ? `<figcaption class=\"text-small\">${caption}</figcaption>`\n : '';\n\n return `<figure><div class=\"image-row\">\n ${imageData\n .map(\n (img) =>\n `<div class=\"image-row__item\" style=\"--aspect-ratio: ${img.aspectRatio}\">\n <img src=\"${img.placeholder}\"\n data-srcset=\"${img.srcset}\"\n data-sizes=\"auto\"\n decoding=\"async\"\n class=\"lazyload\"\n loading=\"lazy\"\n alt=\"${img.alt}\">\n </div>`\n )\n .join('')}\n </div>\n ${captionHtml}\n </figure>`;\n\nConstruct the HTML, including the attributes for lazysizes.\n\n### Handle errors\n\n\n } catch (error) {\n console.error(\"Error processing image row: \", error);\n return `<div class=\"error\">Image could not be displayed.</div>`;\n }\n\nReturn an error in the console and HTML if we run into any issues.\n\n## Wrapping up\n\n**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.\n\nYou can view the code on GitHub if you want to adapt it for your own project!",
"title": "Creating proportional, equal-height image rows with CSS, 11ty, and Nunjucks",
"updatedAt": "2025-06-15T00:00:00.000Z"
}