Building a Responsive Image Grid for Modern Websites
A developer-first pattern for fast, accessible, CLS-safe image grids using CSS Grid (with a lightweight Flexbox fallback), modern media formats, and build-time automation.
Why Grid First (and How to Avoid CLS)
CSS Grid gives you reliable, gap-controlled layouts that adapt to any viewport without JS. The secret to a smooth experience is reserving space so the page doesn’t jump while images load:
use the width
and height
HTML attributes (or aspect-ratio
) and set object-fit: cover
for crops.
- Performance: next-gen formats (
avif
/webp
),srcset
/sizes
, lazy loading. - Accessibility: descriptive
alt
; captions in<figure>
/<figcaption>
. - Maintainability: a single utility-first stylesheet or Tailwind classes; optional container queries.
Semantic Markup Scaffold
Start with semantic containers so screen readers and search engines understand the gallery. Each item is a <figure>
with a caption (optional).
<section aria-labelledby="gallery-title" class="gallery">
<h2 id="gallery-title" class="sr-only">Project Gallery</h2>
<figure class="gallery__item">
<picture>
<source srcset="/img/hero-1200.avif 1200w, /img/hero-800.avif 800w" type="image/avif">
<source srcset="/img/hero-1200.webp 1200w, /img/hero-800.webp 800w" type="image/webp">
<img
src="/img/hero-800.jpg"
srcset="/img/hero-800.jpg 800w, /img/hero-1200.jpg 1200w"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
width="1200" height="800"
alt="Redesign of a marketing homepage with bold headline"
loading="lazy" decoding="async" class="gallery__img">
</picture>
<figcaption class="gallery__caption">Homepage redesign</figcaption>
</figure>
<!-- Repeat items... -->
</section>
The explicit width
/height
preserves aspect ratio and prevents CLS. sizes
tells the browser the expected rendered width for optimal source selection.
Option A: Pure CSS Grid (Auto-Fit Columns)
This pattern creates fluid columns that snap as space allows. It’s minimal, fast, and works in all modern browsers.
/* gallery.css */
.gallery{
--gap: 1rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--gap);
align-items: start;
}
.gallery__item{
display: grid; /* allows caption to size below image */
gap: .5rem;
}
.gallery__img{
width: 100%;
height: auto;
aspect-ratio: 3 / 2; /* safety if width/height omitted */
object-fit: cover; /* crops nicely in varying cells */
border-radius: .5rem;
background: #f4f6f8; /* placeholder color */
}
.gallery__caption{
font-size: .875rem;
color: #6b7280; /* gray-500 */
}
Tweak minmax(220px, 1fr)
to control the smallest card size; increase to reduce column count on small screens.
Option B: Flexbox Rows (Simple Fallback)
Flexbox won’t align rows like Grid, but it’s a fine fallback if you need broad compatibility or want a quick drops-in-anywhere solution.
.gallery{
--gap: 1rem;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.gallery__item{ width: calc(50% - var(--gap)/2); }
@media (min-width: 640px){ .gallery__item{ width: calc(33.333% - var(--gap)*2/3); } }
@media (min-width: 1024px){ .gallery__item{ width: calc(25% - var(--gap)*3/4); } }
Tailwind-First Variant (One-Liners)
If you’re using Tailwind, the same layout is one class away:
<section aria-labelledby="gallery-title"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<figure class="space-y-2">
<img class="w-full aspect-[3/2] object-cover rounded-xl bg-gray-100"
src="/img/hero-800.jpg" width="1200" height="800"
alt="Homepage redesign" loading="lazy" decoding="async">
<figcaption class="text-sm text-gray-500">Homepage redesign</figcaption>
</figure>
<!-- ... -->
</section>
Turn Tiles Into Linkable Cards (Keyboard & Screen Reader Safe)
Wrap the media and caption in a single focusable link. Use a visually subtle, high-contrast focus style.
<a href="/case-studies/homepage/"
class="group block focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#d4a856] rounded-lg">
<figure class="space-y-2">
<img ... class="w-full aspect-[3/2] object-cover rounded-lg group-hover:opacity-95 transition">
<figcaption class="text-sm text-gray-600">Homepage redesign</figcaption>
</figure>
</a>
Performance: Formats, Srcset, and Byte Budgets
- Formats:
avif
→webp
→jpg/png
fallback in<picture>
. - Srcset/sizes: ship multiple widths; use realistic
sizes
so the browser picks correctly. - Lazy load:
loading="lazy"
(but eager-load above-the-fold hero). - Preload criticals: for the first visible image when it’s LCP.
- Automation: generate derivatives at build time (sharp/Squoosh) and enforce a byte budget (e.g., ≤ 150KB per tile).
// scripts/make-images.js (Node + sharp)
import sharp from "sharp"; import fg from "fast-glob"; import { dirname } from "node:path"; import { mkdirSync } from "node:fs";
for (const src of await fg("src/images/*.{jpg,jpeg,png}")){
const base = src.replace(/^src\/images\//,"").replace(/\.(jpe?g|png)$/i,"");
const sizes = [640, 800, 1200];
for (const w of sizes){
const out = `dist/images/${base}-${w}.webp`;
mkdirSync(dirname(out), { recursive: true });
await sharp(src).resize({ width: w }).webp({ quality: 78 }).toFile(out);
}
}
Optional: Container Queries for Smarter Grids
If the gallery lives inside a narrow column on some pages and full-width on others, container queries adapt columns based on parent width—not the viewport.
.gallery-wrap{ container-type: inline-size; }
@container (min-width: 40rem){
.gallery{ grid-template-columns: repeat(3, 1fr); }
}
@container (min-width: 64rem){
.gallery{ grid-template-columns: repeat(4, 1fr); }
}
“Masonry” Looks Without Heavy JS (Progressive)
True CSS masonry support varies, so stick to progressive enhancement. The column-based approach below creates a Pinterest-style flow with minimal code:
.masonry{ column-count: 1; column-gap: 1rem; }
@media (min-width: 640px){ .masonry{ column-count: 2; } }
@media (min-width: 1024px){ .masonry{ column-count: 3; } }
.masonry figure{ break-inside: avoid; margin: 0 0 1rem; }
Use Grid for most galleries; switch to columns when you need the staggered aesthetic and can accept uneven row bottoms.
Optional: Tiny, Accessible Lightbox
Keep JS minimal and accessible. Use a dialog with focus trapping and Esc to close.
<dialog id="lb" class="backdrop:bg-black/70 p-0 rounded-lg">
<button class="absolute top-3 right-3 p-2" aria-label="Close" data-close>✕</button>
<img alt="" id="lb-img" class="max-h-[90vh] max-w-[90vw] object-contain">
<p id="lb-cap" class="p-3 text-sm text-gray-200"></p>
</dialog>
<script>
const d = document.getElementById('lb'), img = document.getElementById('lb-img'), cap = document.getElementById('lb-cap');
document.addEventListener('click', e=>{
const a = e.target.closest('[data-lightbox]');
if(!a) return;
e.preventDefault();
img.src = a.href; img.alt = a.dataset.alt || '';
cap.textContent = a.dataset.caption || '';
d.showModal();
});
d.addEventListener('click', e=>{ if(e.target.matches('[data-close], dialog')) d.close(); });
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && d.open) d.close(); });
</script>
Link each thumbnail with data-lightbox
, data-alt
, and data-caption
to populate the dialog.
QA Checklist (Ship With Confidence)
- ✅ No layout shifts: images have
width
/height
oraspect-ratio
. - ✅
srcset
/sizes
pick correct source at each breakpoint. - ✅ Alt text describes the image; captions are used when context adds value.
- ✅ Keyboard: tab order reaches links/cards; focus ring visible; lightbox closes on Esc.
- ✅ Byte budget respected; thumbnails ≤ 150KB; LCP stays ≤ 2.5s on mobile.
FAQs
Should I use aspect-ratio
or width/height attributes?
Use width/height whenever you know the intrinsic size—it’s the most robust CLS fix. aspect-ratio
is a solid fallback for unknown dimensions.
Is AVIF worth it over WebP?
AVIF typically compresses smaller at similar quality, especially for photos. Provide both via <picture>
for broad coverage.
How many breakpoints do I need in srcset
?
Three to four well-chosen widths (e.g., 640, 800, 1200, 1600) plus an accurate sizes
string beats ten random sizes.
Key Takeaways
- Grid first, Flexbox as a simple fallback; reserve space to eliminate CLS.
- Use
<picture>
withavif/webp
, accuratesrcset
/sizes
, and lazy loading. - Container queries make galleries adapt to their parent, not just the viewport.
- Keep it accessible: alt text, captions, focus styles, and an escape-able lightbox.