LearnAI ToolsCareerPractice BuildsPlayContact
CSSIntermediate~1.5 hours

Photo Gallery

Create a responsive image gallery with hover effects and captions.

CSS GridTransitionsMedia Queries

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.

What You'll Build
  • 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);
}
}
Live Preview

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.