LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.