
How to Build a Responsive Image Gallery with CSS Grid
Image galleries show up on portfolios, product pages, travel blogs, real estate listings, and team pages. They look simple, but getting one right means juggling a lot: columns that adapt to any screen width, images that fill their tiles without distortion, consistent gaps, captions that stay readable, and pages that still load quickly with dozens of photos.
CSS Grid handles almost all of it. In this guide we'll build a responsive gallery from scratch, starting with a one-line auto-fitting grid and working up to featured tiles, captions, hover effects, a dense packing layout, and the image-loading details that keep the whole thing fast.
The Markup
Start with semantic, lightweight HTML. A list works well because a gallery is a collection of related items, and screen readers announce how many there are:
<ul class="gallery" role="list">
<li class="gallery__item">
<img
src="/photos/harbor-640.jpg"
srcset="/photos/harbor-640.jpg 640w, /photos/harbor-1280.jpg 1280w"
sizes="(min-width: 1200px) 300px, (min-width: 640px) 33vw, 50vw"
width="1280"
height="960"
alt="Fishing boats moored in a quiet harbor at dawn"
loading="lazy"
decoding="async"
/>
</li>
<li class="gallery__item">
<img
src="/photos/cliffs-640.jpg"
width="1280"
height="853"
alt="Chalk cliffs above a turquoise bay"
loading="lazy"
decoding="async"
/>
</li>
<!-- more items -->
</ul>
The role="list" restores list semantics that Safari's VoiceOver drops when you remove list styling. The width and height attributes let the browser reserve space before the image loads, which prevents layout shift. We'll come back to srcset, sizes, and lazy loading later.
Step 1: The Auto-Fitting Grid
Here's the core of the gallery:
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 14rem), 1fr));
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
That single grid-template-columns line does the responsive work, with no media queries:
minmax(14rem, 1fr)says each column should be at least 14rem wide, and can grow to share leftover space equally.repeat(auto-fill, ...)creates as many columns as fit in the container.min(100%, 14rem)prevents overflow on very narrow screens, where even 14rem might be wider than the container. The column minimum becomes the container's full width instead.
On a phone you get one or two columns, on a tablet three, and on a wide monitor five or six. Resize the window and the grid reflows smoothly.
auto-fill vs. auto-fit
The difference only shows when there are fewer items than would fill a row:
auto-fillkeeps empty column tracks, so three photos in a wide container stay at their natural size, leaving space on the right.auto-fitcollapses empty tracks, so those three photos stretch to fill the whole row.
For galleries, auto-fill usually looks better because tiles stay a consistent size no matter how many photos there are.
Step 2: Making Images Fill Their Tiles
Photos come in different aspect ratios. To get a tidy grid of equal tiles, give every image the same ratio and let object-fit handle the cropping:
.gallery__item {
margin: 0;
}
.gallery__item img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 0.5rem;
background: #e5e7eb;
}
display: blockremoves the small gap under inline images caused by text baseline alignment.aspect-ratio: 4 / 3gives every tile the same shape regardless of the source image.object-fit: coverscales the image to fill the box, cropping the overflow, likebackground-size: coverfor real images.- The background color shows as a placeholder while the image loads.
If an important subject gets cropped, adjust the focal point with object-position:
.gallery__item img.focus-top {
object-position: center 20%;
}
Step 3: Adding Captions
For captions, use figure and figcaption inside each list item:
<li class="gallery__item">
<figure class="photo">
<img
src="/photos/market-640.jpg"
width="1280"
height="960"
alt="Crates of oranges at a morning market"
loading="lazy"
/>
<figcaption class="photo__caption">Saturday market, Valencia</figcaption>
</figure>
</li>
A common design overlays the caption on the photo with a gradient for contrast. Grid makes the overlay easy, without absolute positioning, by placing both children in the same cell:
.photo {
display: grid;
margin: 0;
border-radius: 0.5rem;
overflow: hidden;
}
.photo > * {
grid-area: 1 / 1;
}
.photo img {
width: 100%;
height: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.photo__caption {
align-self: end;
padding: 2rem 0.875rem 0.75rem;
background: linear-gradient(to top, rgb(0 0 0 / 0.7), transparent);
color: #ffffff;
font-size: 0.875rem;
}
grid-area: 1 / 1 stacks the image and caption in the first cell, and align-self: end drops the caption to the bottom. Unlike absolute positioning, the figure still sizes itself correctly from its content.
Step 4: Hover and Focus Effects
If the images link to a larger view, wrap them in links and add a subtle zoom:
<li class="gallery__item">
<a class="photo-link" href="/photos/harbor-2400.jpg">
<figure class="photo">
<img
src="/photos/harbor-640.jpg"
width="1280"
height="960"
alt="Fishing boats moored in a quiet harbor at dawn"
loading="lazy"
/>
<figcaption class="photo__caption">Harbor at dawn</figcaption>
</figure>
</a>
</li>
.photo-link {
display: block;
border-radius: 0.5rem;
color: inherit;
}
.photo img {
transition: scale 0.4s ease;
}
@media (hover: hover) {
.photo-link:hover .photo img {
scale: 1.05;
}
}
.photo-link:focus-visible {
outline: 3px solid #38bdf8;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.photo img {
transition: none;
}
}
Because .photo has overflow: hidden, the zoomed image stays inside the tile. The individual scale property is cleaner than transform: scale() because it won't conflict with other transforms.
Step 5: Featured Tiles
A gallery with every tile the same size can feel monotonous. Grid lets any item span multiple columns and rows:
.gallery {
grid-auto-rows: 12rem;
}
.gallery__item img,
.gallery__item .photo {
height: 100%;
aspect-ratio: auto;
}
.gallery__item--wide {
grid-column: span 2;
}
.gallery__item--tall {
grid-row: span 2;
}
.gallery__item--feature {
grid-column: span 2;
grid-row: span 2;
}
Here we switch from aspect-ratio tiles to fixed row heights with grid-auto-rows, so spans line up neatly: a tall tile is exactly two rows plus one gap. The images fill whatever area their item gets, thanks to height: 100% and object-fit: cover.
There's one catch. On a phone with a single column, span 2 creates an implicit second column and breaks the layout. Move the column spans out of the unconditional rules and apply them only once there's room (row spans are safe at any width):
@media (min-width: 40rem) {
.gallery__item--wide,
.gallery__item--feature {
grid-column: span 2;
}
}
Better still, use a container query, so the gallery responds to its own width rather than the viewport. That's useful when the same gallery appears in a narrow sidebar and a wide main column:
.gallery-wrap {
container-type: inline-size;
}
.gallery__item--wide,
.gallery__item--feature {
grid-column: auto;
}
@container (min-width: 36rem) {
.gallery__item--wide,
.gallery__item--feature {
grid-column: span 2;
}
}
Container queries are supported in all current major browsers.
Step 6: Filling the Gaps with Dense Packing
When items span different sizes, the grid can leave holes: a wide tile that doesn't fit at the end of a row gets pushed to the next row, leaving empty cells behind. grid-auto-flow: dense tells the grid to backfill those holes with later, smaller items:
.gallery {
grid-auto-flow: dense;
}
The result is a tightly packed mosaic. The trade-off is that the visual order no longer matches the DOM order. Keyboard users tabbing through linked photos will jump around the screen. For purely decorative galleries that's usually acceptable; for galleries where order matters, such as a step-by-step photo series, leave dense off.
Step 7: A Fixed Editorial Layout
Sometimes a design calls for a specific arrangement rather than an auto-flowing grid, such as a hero photo with smaller shots beside it. Named template areas make this readable:
.editorial-gallery {
display: grid;
gap: 0.75rem;
grid-template-columns: 1fr;
grid-template-areas:
"hero"
"one"
"two"
"three";
}
@media (min-width: 48rem) {
.editorial-gallery {
grid-template-columns: 2fr 1fr 1fr;
grid-template-rows: 14rem 14rem;
grid-template-areas:
"hero one two"
"hero three three";
}
}
.editorial-gallery > :nth-child(1) {
grid-area: hero;
}
.editorial-gallery > :nth-child(2) {
grid-area: one;
}
.editorial-gallery > :nth-child(3) {
grid-area: two;
}
.editorial-gallery > :nth-child(4) {
grid-area: three;
}
.editorial-gallery img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.5rem;
}
On mobile, the photos simply stack. On wider screens, the hero takes the left two-thirds across both rows, with three photos arranged beside it. You can read the layout straight from the grid-template-areas strings.
Performance: Loading Images Efficiently
A gallery can easily include dozens of large images, so loading strategy matters as much as layout.
- Lazy load below-the-fold images with
loading="lazy". Don't lazy load the first few visible images; they should load immediately, and the first one can even usefetchpriority="high"if it's the page's main content. - Always set
widthandheightattributes (or a CSSaspect-ratio) to prevent layout shift as images arrive. - Serve responsive sizes with
srcsetandsizes, so phones don't download desktop-sized files. Thesizesvalue should roughly describe the tile width at each breakpoint. - Use modern formats like AVIF or WebP, with a
pictureelement if you need a JPEG fallback. - Keep thumbnails small. Link to the full-resolution image for the enlarged view rather than scaling a huge file down in the grid.
<picture>
<source
type="image/avif"
srcset="/photos/harbor-640.avif 640w, /photos/harbor-1280.avif 1280w"
sizes="(min-width: 640px) 33vw, 50vw"
/>
<img
src="/photos/harbor-640.jpg"
srcset="/photos/harbor-640.jpg 640w, /photos/harbor-1280.jpg 1280w"
sizes="(min-width: 640px) 33vw, 50vw"
width="1280"
height="960"
alt="Fishing boats moored in a quiet harbor at dawn"
loading="lazy"
/>
</picture>
Accessibility Tips
- Write meaningful
alttext that describes each photo. If a caption already describes it fully, alt text can be shorter, but it should never be the filename. - If photos open a larger view, make them real links or buttons, so they're keyboard accessible.
- Keep captions high contrast. The gradient overlay above helps; test with light photos, not just dark ones.
- Be careful with
grid-auto-flow: denseand visual reordering, as discussed.
Conclusion
A responsive image gallery comes down to a few Grid techniques. repeat(auto-fill, minmax(min(100%, 14rem), 1fr)) gives you a fluid column count without media queries. aspect-ratio and object-fit: cover make mixed photos into tidy tiles. Stacking children in one grid cell creates caption overlays without absolute positioning, and spans plus grid-auto-flow: dense turn a plain grid into a mosaic.
Combine that with responsive images, lazy loading, and good alt text, and you have a gallery that looks great, adapts to any container, and loads fast. Start with the one-line grid, and add featured tiles and captions as your design needs them.


