Skip to content

The Photo Gallery Lightbox

A lightbox opens a full-screen overlay when a user clicks an image. The overlay shows the image large, with a close button. Pressing Escape also closes it. While open, the page behind does not scroll.

This is the most visually impressive feature of the module. It is built entirely from scratch — no library, no framework.

  • Clicking any tour card image opens it full-screen in an overlay
  • A close button dismisses the overlay
  • Pressing Escape also dismisses it
  • The page behind stops scrolling while the lightbox is open

Every tour card must have an <img class="tour-card-image"> inside a <div class="tour-card-image-wrap">. This was covered in two earlier courses:

  • HTML Foundations (Module 08, Lesson 03) — adds a plain <img> to each tour article
  • CSS Foundations (Module 08, Lesson 04) — wraps the image in .tour-card-image-wrap and applies class="tour-card-image"

Open tours.html and index.html and confirm the hardcoded tour cards already have this structure. If they do not, follow those lessons before continuing.

The JS-rendered cards in main.js also need images. Confirm your tour data includes img and alt fields and that renderTourCard renders the image wrap — this was added in Module 05, Lesson 06 of this course. If it is not yet in place, the complete version is:

const tours = [
{ name: 'Cascade Ridge Hike', price: 149, available: true, category: 'hiking', img: 'images/cascade-ridge-hike.jpg', alt: 'Pine forest with sunlit mountains on the Cascade Ridge Hike' },
{ name: 'Summit Loop Trek', price: 199, available: true, category: 'hiking', img: 'images/summit-loop-trek.jpg', alt: 'Green trees near a mountain lake on the Summit Loop Trek' },
{ name: 'Valley Floor Walk', price: 99, available: false, category: 'walking', img: 'images/valley-floor-walk.jpg', alt: 'Fern-lined forest path on the Valley Floor Walk' },
];
function renderTourCard(tour) {
const status = tour.available ? 'Available' : 'Sold Out';
return `
<article class="tour-card" data-category="${tour.category}">
<div class="tour-card-image-wrap">
<img class="tour-card-image" src="${tour.img}" alt="${tour.alt}">
</div>
<div class="tour-card-body">
<h3>${tour.name}</h3>
<p class="tour-price">${formatPrice(tour.price)}</p>
<p class="tour-status">${status}</p>
</div>
</article>
`;
}

Build the overlay in JavaScript using createElement. It is not in the HTML — it is created on demand:

function createOverlay(src, alt) {
const overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
const img = document.createElement('img');
img.src = src;
img.alt = alt || '';
img.className = 'lightbox-img';
const closeBtn = document.createElement('button');
closeBtn.className = 'lightbox-close';
closeBtn.textContent = 'Close';
closeBtn.setAttribute('aria-label', 'Close lightbox');
overlay.append(img, closeBtn);
return overlay;
}

Step 3 — Wire image clicks with delegation

Section titled “Step 3 — Wire image clicks with delegation”

The STO tours page has three separate .tours-grid sections (Day Hikes, Full-Day Adventures, Overnight Trips). Attaching the listener to a single container would miss the other two. Instead, delegate from document and use the .tour-card-image class as the guard — only images with that class trigger the lightbox:

document.addEventListener('click', (event) => {
const img = event.target.closest('.tour-card-image');
if (!img) return;
const overlay = createOverlay(img.src, img.alt);
document.body.appendChild(overlay);
document.body.classList.add('no-scroll');
overlay.querySelector('.lightbox-close').addEventListener('click', closeOverlay);
});

closest('.tour-card-image') is specific enough that no other image on the site accidentally opens a lightbox.

document.body.classList.add('no-scroll') pairs with a CSS rule already in styles.css:

body.no-scroll {
overflow: hidden;
}

The overlay, image, and close button also need CSS. Add this to styles.css:

/* === Lightbox === */
.lightbox-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.lightbox-img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: var(--radius-md);
}
.lightbox-close {
position: absolute;
top: var(--space-md);
right: var(--space-md);
background: rgba(255, 255, 255, 0.15);
color: var(--color-white);
border: 2px solid rgba(255, 255, 255, 0.4);
border-radius: var(--radius-sm);
padding: var(--space-xs) var(--space-md);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: background-color 150ms ease;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.3);
}

position: fixed; inset: 0 stretches the overlay to fill the entire viewport regardless of scroll position. z-index: 200 places it above the sticky header (which uses z-index: 100).

function closeOverlay() {
const overlay = document.querySelector('.lightbox-overlay');
if (overlay) {
overlay.remove();
document.body.classList.remove('no-scroll');
}
}

Removing the overlay from the DOM is cleaner than hiding it — no stale state.

document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeOverlay();
}
});

This listener lives permanently on the document. closeOverlay has a null guard — if no overlay exists, calling it does nothing.

This is the code added to main.js. container is already in scope from the tour card rendering section.

function createOverlay(src, alt) {
const overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
const img = document.createElement('img');
img.src = src;
img.alt = alt || '';
img.className = 'lightbox-img';
const closeBtn = document.createElement('button');
closeBtn.className = 'lightbox-close';
closeBtn.textContent = 'Close';
closeBtn.setAttribute('aria-label', 'Close lightbox');
overlay.append(img, closeBtn);
return overlay;
}
function closeOverlay() {
const overlay = document.querySelector('.lightbox-overlay');
if (overlay) {
overlay.remove();
document.body.classList.remove('no-scroll');
}
}
document.addEventListener('click', (event) => {
const img = event.target.closest('.tour-card-image');
if (!img) return;
const overlay = createOverlay(img.src, img.alt);
document.body.appendChild(overlay);
document.body.classList.add('no-scroll');
overlay.querySelector('.lightbox-close').addEventListener('click', closeOverlay);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeOverlay();
}
});
  1. Click a tour image — overlay opens with the image full-size
  2. Click the close button — overlay removes, scroll restores
  3. Click another image, then press Escape — overlay closes
  4. Scroll the page while the lightbox is open — body stays locked
  5. Open DevTools — confirm .lightbox-overlay is added to and removed from body on each open/close
  • The overlay is created dynamically with createElement and removed with .remove() — no hidden state in the DOM.
  • Delegation on the container with closest('img') handles every image without individual listeners.
  • document.body.classList.add('no-scroll') + CSS overflow: hidden locks the scroll.
  • The Escape key handler uses a null-guarded closeOverlay — safe to call whether or not an overlay is currently open.