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-ratioandobject-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 therepeat()/minmax()functions. - The
transitionproperty — animating property changes over time instead of jumping instantly. - The
:hoverpseudo-class, including hovering a parent to affect a specific child element. - Positioning —
position: relativeon a container andposition: absoluteon an overlay inside it. - Basic familiarity with
<figure>/<figcaption>and theobject-fit/aspect-ratioproperties.
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
:targetselector so clicking a photo opens a full-size overlay view, all in CSS. - Vary the
aspect-ratioon 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-gradientcaption background with abackdrop-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.