Overview
A photo gallery is the ideal project for practicing two CSS features together: Grid, for laying out a variable number of photos in a clean, evenly spaced arrangement, and transitions, for the hover-triggered zoom-and-caption effect that makes a gallery feel alive instead of static. Unlike the pricing page's fixed three columns, this gallery uses an `auto-fill`/`minmax` grid that automatically adds or removes columns as the viewport resizes — no media query is even required to make the column count responsive.
Each photo card is built from a `<figure>`/`<figcaption>` pair — the semantically correct HTML for an image with an associated caption — and the caption itself is hidden below the image by default, sliding into view with a smooth transition when the card is hovered.
- A responsive photo grid that adds or removes columns automatically as the screen resizes.
- Uniform, cropped photo cards using `aspect-ratio` and `object-fit: cover`.
- A caption overlay that stays hidden below each photo until it is hovered.
- A smooth zoom-in effect on the photo and a slide-up effect on the caption, both driven by `transition`.
- A media query that keeps captions visible by default on touch devices, which cannot hover.
Prerequisites
- CSS Grid basics — `display: grid`, `grid-template-columns`, and the `repeat()`/`minmax()` functions.
- The `transition` property — animating property changes over time instead of jumping instantly.
- The `:hover` pseudo-class, including hovering a parent to affect a specific child element.
- Positioning — `position: relative` on a container and `position: absolute` on an overlay inside it.
- Basic familiarity with `<figure>`/`<figcaption>` and the `object-fit`/`aspect-ratio` properties.
Project Structure
The markup below is fixed for the rest of the project: a `<section class="gallery">` containing a heading and a `.gallery__grid` of `<figure class="gallery__item">` elements, each wrapping an `<img>` and a `<figcaption>`. Every figure is structurally identical, which is what lets one shared set of CSS rules style every photo in the gallery consistently. Placeholder images come from `placehold.co`, a reliable public placeholder-image service.
<!-- Gallery section: a heading plus a grid of identically structured photo cards --><section class="gallery"> <h1 class="gallery__title">Photo Gallery</h1>
<div class="gallery__grid"> <!-- Each figure pairs one photo with one caption — the semantically correct HTML for this --> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Mountain landscape at sunrise" class="gallery__img"> <figcaption class="gallery__caption">Mountain Sunrise</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="City skyline at night" class="gallery__img"> <figcaption class="gallery__caption">City Lights</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Forest path in autumn" class="gallery__img"> <figcaption class="gallery__caption">Autumn Trail</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Ocean waves on a beach" class="gallery__img"> <figcaption class="gallery__caption">Coastal Waves</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Desert dunes at dusk" class="gallery__img"> <figcaption class="gallery__caption">Desert Dusk</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Snow-capped mountain peaks" class="gallery__img"> <figcaption class="gallery__caption">Winter Peaks</figcaption> </figure> </div></section>Step 1: Reset Defaults and Build the Page Shell
A small reset, a dark page background that makes photos feel like they are lit from within, and a centered container with a page title above the grid.
* { margin: 0; padding: 0; box-sizing: border-box;}
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: #0f172a; /* Dark background makes the photos feel like they're lit from within */ color: #f1f5f9;}
.gallery { max-width: 1100px; margin: 0 auto; padding: 3rem 1.5rem;}
.gallery__title { text-align: center; font-size: 2rem; margin-bottom: 2rem; /* Separates the title from the grid of photos below it */}Step 2: Lay Out a Responsive Grid
`repeat(auto-fill, minmax(240px, 1fr))` is the key line of this whole project: it tells the browser to fit as many 240px-or-wider columns as will comfortably fit the container, then stretch them evenly (`1fr`) to fill any leftover space — automatically going from six columns on a wide screen down to one on a phone, with no media query needed for the column count itself.
.gallery__grid { display: grid; /* auto-fill: repeat as many columns as fit; minmax(240px, 1fr): each is at least 240px, then shares leftover space evenly */ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1.5rem; /* Even spacing between every photo card, in both directions */}Step 3: Style the Photo Cards
Photos rarely arrive in a uniform shape, so `aspect-ratio: 4 / 3` locks every card to the same rectangle regardless of the source image's real dimensions, and `object-fit: cover` crops the image to fill that rectangle without distorting it. `overflow: hidden` on the card is what will clip the zoomed-in image cleanly in Step 5.
.gallery__item { position: relative; /* Positioning context for the caption overlay added in Step 4 */ overflow: hidden; /* Clips the zoomed image (Step 5) to the card's rounded rectangle */ border-radius: 10px; aspect-ratio: 4 / 3; /* Forces every card to the same shape, regardless of the source image's size */ background: #1e293b; /* Visible briefly while the image loads, or if it fails to load */}
.gallery__img { width: 100%; height: 100%; object-fit: cover; /* Fills the fixed aspect-ratio box by cropping, never stretching or distorting */ display: block; /* Removes the few pixels of gap that inline <img>s get by default under text */ transition: transform 0.4s ease; /* Animates the hover zoom that Step 5 adds */}Step 4: Position the Caption Overlay
The caption sits absolutely positioned over the bottom of the photo, on a gradient that fades from solid black into transparency so it stays legible over any photo without a hard-edged box. `transform: translateY(100%)` pushes it fully out of view below the card's bottom edge — this is the "hidden" starting state that Step 5's hover rule will animate away from.
.gallery__caption { position: absolute; /* Floats over the image instead of pushing it down in normal document flow */ left: 0; right: 0; bottom: 0; padding: 0.75rem 1rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent); /* Fades into the photo, not a hard edge */ font-size: 0.9rem; font-weight: 600; transform: translateY(100%); /* Pushed fully below the card's visible area — effectively hidden */ transition: transform 0.3s ease; /* Animates the slide-up reveal added in Step 5 */}Step 5: Add Hover Transitions
Both effects are triggered the same way: hovering `.gallery__item` (the whole card) reaches into its children — the image and the caption — and changes their `transform`. Because both properties already declared a `transition` in earlier steps, the browser animates smoothly between the resting and hovered states instead of snapping instantly.
.gallery__item:hover .gallery__img { transform: scale(1.08); /* Slight zoom-in on hover signals that the card is interactive */}
.gallery__item:hover .gallery__caption { transform: translateY(0); /* Slides the caption up from below the card into full view */}Step 6: Polish with Media Queries
Grid's `auto-fill` already handles column count responsively, but a media query still improves two things on small screens: a smaller minimum card width so more than one column still fits a narrow phone, and — since touch devices have no real `:hover` state — captions are shown by default instead of hidden, so mobile users are never stuck unable to see a caption at all.
@media (max-width: 640px) { .gallery__grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); /* Smaller minimum so more columns still fit */ gap: 1rem; }
.gallery__title { font-size: 1.5rem; }
.gallery__caption { transform: translateY(0); /* Touch devices can't hover, so keep captions visible by default here */ background: rgba(0, 0, 0, 0.55); /* Flat tint instead of the gradient, since it's always showing now */ }}Complete Code
Here is the fixed HTML markup again for reference, followed by the complete stylesheet with every rule from Steps 1 through 6 assembled in order.
<section class="gallery"> <h1 class="gallery__title">Photo Gallery</h1>
<div class="gallery__grid"> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Mountain landscape at sunrise" class="gallery__img"> <figcaption class="gallery__caption">Mountain Sunrise</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="City skyline at night" class="gallery__img"> <figcaption class="gallery__caption">City Lights</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Forest path in autumn" class="gallery__img"> <figcaption class="gallery__caption">Autumn Trail</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Ocean waves on a beach" class="gallery__img"> <figcaption class="gallery__caption">Coastal Waves</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Desert dunes at dusk" class="gallery__img"> <figcaption class="gallery__caption">Desert Dusk</figcaption> </figure> <figure class="gallery__item"> <img src="https://placehold.co/400x300" alt="Snow-capped mountain peaks" class="gallery__img"> <figcaption class="gallery__caption">Winter Peaks</figcaption> </figure> </div></section>* { margin: 0; padding: 0; box-sizing: border-box;}
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f1f5f9;}
.gallery { max-width: 1100px; margin: 0 auto; padding: 3rem 1.5rem;}
.gallery__title { text-align: center; font-size: 2rem; margin-bottom: 2rem;}
.gallery__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1.5rem;}
.gallery__item { position: relative; overflow: hidden; border-radius: 10px; aspect-ratio: 4 / 3; background: #1e293b;}
.gallery__img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.4s ease;}
.gallery__caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 0.75rem 1rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.75), transparent); font-size: 0.9rem; font-weight: 600; transform: translateY(100%); transition: transform 0.3s ease;}
.gallery__item:hover .gallery__img { transform: scale(1.08);}
.gallery__item:hover .gallery__caption { transform: translateY(0);}
@media (max-width: 640px) { .gallery__grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; }
.gallery__title { font-size: 1.5rem; }
.gallery__caption { transform: translateY(0); background: rgba(0, 0, 0, 0.55); }}Sample Run
The preview above shows six evenly spaced, uniformly cropped photo cards flowing across the available width — resize the preview narrower and the grid automatically drops from several columns down to one, with no extra CSS required beyond the `auto-fill`/`minmax` rule from Step 2. Hover any card and its image zooms in slightly while its caption slides up from the bottom on a soft gradient; move the mouse away and both animate smoothly back to their resting state.
Extend This Project
- Add a lightbox effect using the checkbox-hack or `:target` selector so clicking a photo opens a full-size overlay view, all in CSS.
- Vary the `aspect-ratio` on alternating cards (e.g. some 4/3, some 1/1) for a more magazine-style masonry look.
- Add category filter buttons and use the `:has()` selector or extra classes to show/hide matching photos.
- Add a subtle `filter: grayscale(40%)` on resting photos that clears to full color on hover, layered on top of the existing zoom transition.
- Replace the `linear-gradient` caption background with a `backdrop-filter: blur(4px)` for a frosted-glass caption bar instead.
Summary
You built a fully responsive photo gallery whose column count adapts automatically via `auto-fill`/`minmax`, with hover-triggered zoom and caption transitions layered on top using nothing but `transform` and `transition`. The pattern of hiding an overlay with `transform: translateY(100%)` and revealing it with `:hover` is one of the most common building blocks in real interface design — you will reuse it for tooltips, dropdown panels, and reveal-on-hover cards far beyond photo galleries.