Overview
An image gallery looks simple — a grid of pictures — but it's a great project for two HTML features that don't come up until you need them: `<figure>`/`<figcaption>` for pairing an image with a real caption, not just a paragraph sitting near it, and fragment-identifier links, which can build a working "lightbox" that enlarges an image on click without a single line of JavaScript.
This tutorial builds a six-photo gallery where every thumbnail links to a full-size overlay view, using nothing but anchor tags, ids, and a small CSS rule that shows or hides content based on the URL's fragment (the part after `#`). It is a real, working feature, not a simplified stand-in for one.
- A gallery grid of six images, each wrapped in a `<figure>` with a real `<figcaption>`.
- A lightbox link structure — clicking a thumbnail opens a full-size overlay.
- A "close" link inside the overlay that returns to the plain gallery view.
- Meaningful `alt` text on every image, distinct from its caption.
- A layout that reflows into fewer columns as the viewport narrows.
Prerequisites
- The `<img>` element and its `src`/`alt`/`width`/`height` attributes.
- Basic anchor links (`<a href="...">`), including how `href="#id"` jumps to an element with that id.
- Basic HTML document structure from the earlier projects in this course.
- It helps, but isn't required, to have seen basic CSS selectors before this project.
Project Structure
The page is one file, `gallery.html`, with a `<header>`, a `<main>` holding the gallery grid, and a set of overlay `<div id="...">` elements (one per photo) placed right after the grid — each overlay is only shown when the page's URL fragment matches its id, using the CSS `:target` pseudo-class. That's the entire "lightbox": no JavaScript, just anchors, ids, and one CSS rule.
Every photo is a `<figure>` containing an `<img>` and a `<figcaption>` — this pairing exists specifically so a caption is programmatically tied to its image, unlike a `<div><img><p>caption</p></div>`, where nothing in the markup itself says the paragraph describes the image above it.
Step 1: Scaffold the Page and Header
Start with the boilerplate and a simple header.
<!doctype html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Trail & Peak Photography - Gallery</title> <meta name="description" content="A gallery of landscape photography from mountain trails around the world."></head><body>
<header> <h1>Trail & Peak Photography</h1> <p>A small gallery of landscape shots from recent hikes.</p> </header>
<!-- The gallery grid and lightbox overlays are added inside <main> in the steps that follow -->
</body></html>Step 2: Structure One Gallery Item
Each photo is a `<figure>` — the element specifically built for content that stands alone with its own caption, like an illustration, diagram, or photo. Inside it, `<figcaption>` is the caption, and the association is automatic: assistive tech announces the figcaption as this image's caption, something a plain nearby paragraph can never do.
<!-- <figure> pairs self-contained content with its own caption; <figcaption> is that caption, and the two are programmatically linked - unlike a <div> + nearby <p> --><figure> <img src="https://placehold.co/400x300" alt="Sunrise over a mountain ridge with fog in the valley below" width="400" height="300"> <!-- alt describes what's IN the photo, for when it fails to load or is read aloud --> <figcaption>Sunrise, Dhauladhar Range</figcaption> <!-- figcaption is the visible caption - distinct in purpose from the alt text above it --></figure>Step 3: Build the Full Image Grid
Repeating that pattern six times, inside a `<main>` wrapped around a grid container, builds the full gallery. Every `alt` describes the photo's content, while every `figcaption` gives it a human title — the two attributes serve different audiences and shouldn't just repeat each other.
<main> <h2>Gallery</h2> <!-- Plain <div> is fine here: it's a pure layout container with no independent meaning of its own --> <div class="gallery-grid">
<figure> <img src="https://placehold.co/400x300" alt="Sunrise over a mountain ridge with fog in the valley below" width="400" height="300"> <figcaption>Sunrise, Dhauladhar Range</figcaption> </figure>
<figure> <img src="https://placehold.co/400x300" alt="Alpine lake reflecting snow-capped peaks" width="400" height="300"> <figcaption>Mirror Lake</figcaption> </figure>
<figure> <img src="https://placehold.co/400x300" alt="Narrow dirt trail cutting through a pine forest" width="400" height="300"> <figcaption>The Pine Trail</figcaption> </figure>
<figure> <img src="https://placehold.co/400x300" alt="Rocky summit above the cloud line at sunset" width="400" height="300"> <figcaption>Above the Clouds</figcaption> </figure>
<figure> <img src="https://placehold.co/400x300" alt="Waterfall cascading over moss-covered rocks" width="400" height="300"> <figcaption>Hidden Falls</figcaption> </figure>
<figure> <img src="https://placehold.co/400x300" alt="Wildflower meadow with mountains in the background" width="400" height="300"> <figcaption>Meadow in Bloom</figcaption> </figure>
</div></main>Step 4: Link Thumbnails to a Lightbox
Now the "lightbox" behavior: wrap each thumbnail's `<img>` in an anchor linking to a matching id, like `href="#photo-1"`. Clicking it changes the page's URL fragment, which is exactly the hook the next step's CSS uses to show a full-size overlay. No JavaScript is written for this — it's a genuine feature built entirely out of anchors and ids.
<figure> <!-- Wrapping the thumbnail in a link to #photo-1 changes the page's URL fragment on click - the :target CSS rule (added in Step 5) reacts to that fragment to reveal the overlay --> <a href="#photo-1"> <img src="https://placehold.co/400x300" alt="Sunrise over a mountain ridge with fog in the valley below" width="400" height="300"> </a> <figcaption>Sunrise, Dhauladhar Range</figcaption></figure><!-- Every other figure in the grid gets the same treatment: #photo-2, #photo-3, and so on -->Step 5: Build the Lightbox Overlay Markup
Each lightbox overlay is a plain `<div id="photo-1">` placed after the grid, hidden by default and shown only when the URL fragment matches its id — that is the `:target` selector doing the work in CSS. A "Close" link inside sets `href="#"`, which clears the match and hides the overlay again.
<!-- One overlay per photo, placed after the gallery grid. Hidden by default in CSS; shown only when its id matches the current URL fragment (the #photo-1 from Step 4) --><div id="photo-1" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Sunrise over a mountain ridge with fog in the valley below"> <p>Sunrise, Dhauladhar Range</p> <a href="#" class="lightbox-close">× Close</a> <!-- href="#" clears the URL fragment, which un-matches :target and hides this overlay again --> </div></div><!-- Repeated as id="photo-2" through id="photo-6", one overlay per gallery photo -->Step 6: Add the Footer
Finally, a simple footer closes out the page.
<footer> <p>© 2026 Trail & Peak Photography.</p></footer>Complete Code
Here is the full gallery page with all six photos, all six lightbox overlays, and the `:target`-driven CSS that makes the lightbox work.
<!doctype html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Trail & Peak Photography - Gallery</title> <meta name="description" content="A gallery of landscape photography from mountain trails around the world."> <style> * { box-sizing: border-box; } body { font-family: Arial, Helvetica, sans-serif; margin: 0; color: #222; line-height: 1.6; background: #fafafa; } header { background: #2b3a55; color: #fff; text-align: center; padding: 30px 20px; } main { max-width: 900px; margin: 0 auto; padding: 20px; } /* auto-fit + minmax reflows the grid from 3 columns down to 1 as the viewport narrows */ .gallery-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; } figure { margin: 0; background: #fff; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; } figure img { display: block; width: 100%; height: auto; } figure a { display: block; } figcaption { padding: 10px; font-weight: bold; text-align: center; } footer { text-align: center; padding: 20px; background: #2b3a55; color: #d7dce6; }
/* Lightbox: hidden by default, shown only when its id matches the URL fragment */ .lightbox { display: none; /* Hidden until :target matches */ position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); align-items: center; justify-content: center; } .lightbox:target { display: flex; } /* :target matches only the element whose id is the current URL fragment */ .lightbox-content { background: #fff; padding: 16px; border-radius: 6px; text-align: center; max-width: 90%; } .lightbox-content img { max-width: 100%; max-height: 70vh; border-radius: 4px; } .lightbox-close { display: inline-block; margin-top: 10px; color: #2b3a55; font-weight: bold; text-decoration: none; } </style></head><body>
<header> <h1>Trail & Peak Photography</h1> <p>A small gallery of landscape shots from recent hikes.</p> </header>
<main> <h2>Gallery</h2> <div class="gallery-grid">
<figure> <a href="#photo-1"> <img src="https://placehold.co/400x300" alt="Sunrise over a mountain ridge with fog in the valley below" width="400" height="300"> </a> <figcaption>Sunrise, Dhauladhar Range</figcaption> </figure>
<figure> <a href="#photo-2"> <img src="https://placehold.co/400x300" alt="Alpine lake reflecting snow-capped peaks" width="400" height="300"> </a> <figcaption>Mirror Lake</figcaption> </figure>
<figure> <a href="#photo-3"> <img src="https://placehold.co/400x300" alt="Narrow dirt trail cutting through a pine forest" width="400" height="300"> </a> <figcaption>The Pine Trail</figcaption> </figure>
<figure> <a href="#photo-4"> <img src="https://placehold.co/400x300" alt="Rocky summit above the cloud line at sunset" width="400" height="300"> </a> <figcaption>Above the Clouds</figcaption> </figure>
<figure> <a href="#photo-5"> <img src="https://placehold.co/400x300" alt="Waterfall cascading over moss-covered rocks" width="400" height="300"> </a> <figcaption>Hidden Falls</figcaption> </figure>
<figure> <a href="#photo-6"> <img src="https://placehold.co/400x300" alt="Wildflower meadow with mountains in the background" width="400" height="300"> </a> <figcaption>Meadow in Bloom</figcaption> </figure>
</div> </main>
<!-- One overlay per photo; :target in the stylesheet shows whichever one matches the URL fragment --> <div id="photo-1" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Sunrise over a mountain ridge with fog in the valley below"> <p>Sunrise, Dhauladhar Range</p> <a href="#" class="lightbox-close">× Close</a> </div> </div> <div id="photo-2" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Alpine lake reflecting snow-capped peaks"> <p>Mirror Lake</p> <a href="#" class="lightbox-close">× Close</a> </div> </div> <div id="photo-3" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Narrow dirt trail cutting through a pine forest"> <p>The Pine Trail</p> <a href="#" class="lightbox-close">× Close</a> </div> </div> <div id="photo-4" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Rocky summit above the cloud line at sunset"> <p>Above the Clouds</p> <a href="#" class="lightbox-close">× Close</a> </div> </div> <div id="photo-5" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Waterfall cascading over moss-covered rocks"> <p>Hidden Falls</p> <a href="#" class="lightbox-close">× Close</a> </div> </div> <div id="photo-6" class="lightbox"> <div class="lightbox-content"> <img src="https://placehold.co/800x600" alt="Wildflower meadow with mountains in the background"> <p>Meadow in Bloom</p> <a href="#" class="lightbox-close">× Close</a> </div> </div>
<footer> <p>© 2026 Trail & Peak Photography.</p> </footer>
</body></html>Sample Run
Open `gallery.html` in a browser, or use the live preview above, and try the following:
- Click any thumbnail — the URL gains a fragment like `#photo-3` and a full-size overlay of that photo appears, built entirely with anchors, ids, and one CSS `:target` rule.
- Click "Close" inside the overlay — the fragment clears and the overlay disappears, back to the plain grid.
- Use the browser's back button after opening a photo — because the lightbox state lives in the URL fragment, the browser's normal history navigation opens and closes it correctly.
- Resize the window narrower — the gallery grid reflows from three columns down to two, then one, using CSS Grid's `auto-fit` behavior.
Extend This Project
- Add "Next" and "Previous" links inside each lightbox overlay that jump straight to the neighboring photo's id.
- Add a numbered caption ("Photo 3 of 6") inside each overlay using static text per overlay.
- Swap the plain grid `<div>` for a `<ul>`/`<li>` structure and compare whether that is more or less semantically correct for a photo grid — there is a real debate here worth exploring.
- Add `loading="lazy"` to every thumbnail `<img>` so off-screen photos don't load until the user scrolls near them.
- Add a photographer credit line under each image using a second line of text inside the same `<figcaption>`.
Summary
You built a real image gallery with properly captioned `<figure>`/`<figcaption>` pairs and a genuinely working lightbox, driven entirely by anchor links, element ids, and the `:target` CSS selector — no JavaScript required. Understanding how far plain HTML and CSS can go before you need a scripting language is exactly the kind of judgment that separates markup that merely displays information from markup that behaves like a real interface.