Introduction to Custom Lightbox Galleries

A lightbox gallery is a powerful UI pattern that displays images or other media in a modal overlay, dimming the surrounding page content. This technique keeps users engaged without forcing a page navigation, making it a staple for portfolios, e‑commerce product shots, and editorial photo essays. While many developers reach for third‑party libraries, building a custom JavaScript lightbox gives you full control over styling, performance, and accessibility. In this comprehensive guide, you will learn how to create a production‑ready lightbox gallery from scratch using modern HTML, CSS, and JavaScript. You will also discover how to extend it with navigation, captions, keyboard support, and mobile gestures – all while keeping the code lean and maintainable.

Understanding the Lightbox Concept

At its core a lightbox is a fixed‑position overlay that displays a full‑resolution version of a thumbnail image. When activated, it hides the page content behind a semi‑transparent background, preventing interaction with the underlying page until the lightbox is dismissed. This pattern is also known as a “modal image viewer.”

Key characteristics of a well‑designed lightbox include:

  • Focus and concentration – The dimmed background helps users focus on the image.
  • Non‑intrusive close – Clicking the overlay background or a close button should dismiss the modal.
  • Keyboard accessibility – The Esc key should close the lightbox, and tab focus should be trapped inside it.
  • Graceful fallback – If JavaScript fails, users can still open the image via a direct link.

For a deeper introduction to modal design patterns, see the WAI‑ARIA Authoring Practices on Modals.

Building the HTML Structure

Begin with an accessible, semantic markup that works even without JavaScript. Instead of using <img> tags directly, wrap each thumbnail in an anchor element pointing to the full‑resolution image. This provides a native fallback: users who disable scripts can open the image in a new tab.

<div class="gallery">
  <a href="photos/dawn.jpeg" class="gallery__link" data-caption="Dawn over the valley">
    <img src="thumbs/dawn.jpg" alt="Mountain landscape at sunrise" width="300" height="200" loading="lazy">
  </a>
  <a href="photos/forest.jpeg" class="gallery__link" data-caption="Misty forest path">
    <img src="thumbs/forest.jpg" alt="Green forest with fog" width="300" height="200" loading="lazy">
  </a>
  <a href="photos/sea.jpeg" class="gallery__link" data-caption="Waves crashing on rocks">
    <img src="thumbs/sea.jpg" alt="Ocean waves at sunset" width="300" height="200" loading="lazy">
  </a>
</div>

Notice the data-caption attribute – we will use it later to display text below the image. The loading="lazy" attribute defers loading of off‑screen thumbnails, improving initial page performance.

Thumbnail grid

Use CSS Grid or Flexbox to arrange the thumbnails. A simple grid keeps the layout responsive:

.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 1.5rem;
  padding: 1rem;
}
.gallery__link {
  display: block;
  overflow: hidden;
  border-radius: 8px;
  transition: transform 0.2s ease;
}
.gallery__link:hover {
  transform: scale(1.03);
}
.gallery__link img {
  display: block;
  width: 100%;
  height: auto;
}

The overlay should cover the entire viewport, center the image, and include a close button. Use position: fixed and flexbox for centering:

.lightbox {
  display: none; /* hidden by default; shown via JS */
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.85);
  justify-content: center;
  align-items: center;
  z-index: 1000;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.lightbox.active {
  display: flex;
  opacity: 1;
}
.lightbox__image {
  max-width: 90vw;
  max-height: 85vh;
  object-fit: contain;
  border-radius: 4px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.lightbox__close {
  position: absolute;
  top: 1rem;
  right: 1.5rem;
  background: transparent;
  border: none;
  color: #fff;
  font-size: 2.5rem;
  cursor: pointer;
  z-index: 1001;
  line-height: 1;
}

The .active class triggers the display and opacity transition. This avoids abrupt show/hide behaviour.

Implementing JavaScript Functionality

Event handling and opening the lightbox

Use event delegation to handle multiple thumbnails without attaching event listeners to each link. This is more efficient and works with dynamically added content:

const gallery = document.querySelector('.gallery');
const lightbox = document.createElement('div');
lightbox.className = 'lightbox';

// Build lightbox inner HTML
lightbox.innerHTML = `
  <button class="lightbox__close" aria-label="Close">&times;</button>
  <img class="lightbox__image" src="" alt="">
  <figcaption class="lightbox__caption"></figcaption>
`;

document.body.appendChild(lightbox);

const lightboxImg = lightbox.querySelector('.lightbox__image');
const lightboxCaption = lightbox.querySelector('.lightbox__caption');
const closeBtn = lightbox.querySelector('.lightbox__close');

// Open lightbox on gallery link click
gallery.addEventListener('click', (e) => {
  const link = e.target.closest('.gallery__link');
  if (!link) return;
  e.preventDefault();
  const fullSrc = link.getAttribute('href');
  const caption = link.dataset.caption || '';
  lightboxImg.src = fullSrc;
  lightboxImg.alt = link.querySelector('img').alt;
  lightboxCaption.textContent = caption;
  lightbox.classList.add('active');
});

Closing the lightbox

Three ways to close: clicking the close button, clicking the overlay background (but not the image), and pressing the Escape key:

// Close button
closeBtn.addEventListener('click', closeLightbox);

// Background click (close only if target is overlay)
lightbox.addEventListener('click', (e) => {
  if (e.target === lightbox) closeLightbox();
});

// Keyboard
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && lightbox.classList.contains('active')) {
    closeLightbox();
  }
});

function closeLightbox() {
  lightbox.classList.remove('active');
  // Optional: clear src after transition to free memory
  setTimeout(() => { lightboxImg.src = ''; }, 400);
}

Delaying the src removal until after the transition completes prevents a flash of the last image.

Enhancing User Experience

To browse images inside the lightbox without closing it, add previous and next buttons. First, store all gallery links in an array, then track the current index:

const links = [ ...document.querySelectorAll('.gallery__link') ];
let currentIndex = 0;

gallery.addEventListener('click', (e) => {
  const link = e.target.closest('.gallery__link');
  if (!link) return;
  e.preventDefault();
  currentIndex = links.indexOf(link);
  showImage(currentIndex);
});

function showImage(index) {
  const link = links[index];
  lightboxImg.src = link.href;
  lightboxImg.alt = link.querySelector('img').alt;
  lightboxCaption.textContent = link.dataset.caption || '';
  lightbox.classList.add('active');
}

// Arrow buttons (add inside lightbox div)
const prevBtn = document.createElement('button');
prevBtn.innerHTML = '&#10094;';
prevBtn.className = 'lightbox__nav lightbox__prev';
const nextBtn = document.createElement('button');
nextBtn.innerHTML = '&#10095;';
nextBtn.className = 'lightbox__nav lightbox__next';

prevBtn.addEventListener('click', () => {
  currentIndex = (currentIndex - 1 + links.length) % links.length;
  showImage(currentIndex);
});
nextBtn.addEventListener('click', () => {
  currentIndex = (currentIndex + 1) % links.length;
  showImage(currentIndex);
});

// Place them inside lightbox
lightbox.prepend(prevBtn);
lightbox.appendChild(nextBtn);

Style the nav buttons with absolute positioning and semi‑transparent backgrounds. Don’t forget aria-label attributes for screen readers.

Swipe gestures for mobile

Touch events can make the gallery feel native. Implement simple swiping by detecting horizontal movement:

let touchStartX = 0;
lightbox.addEventListener('touchstart', (e) => {
  touchStartX = e.changedTouches[0].screenX;
}, { passive: true });
lightbox.addEventListener('touchend', (e) => {
  const diff = e.changedTouches[0].screenX - touchStartX;
  if (Math.abs(diff) > 50) {
    if (diff > 0) prevBtn.click();
    else nextBtn.click();
  }
});

This works well without interfering with zoom or scroll.

Accessibility improvements

  • Set aria-modal="true" on the lightbox when active.
  • Trap focus inside the lightbox (a simple focus trap script).
  • Ensure all interactive elements have visible focus styles.
  • Use role="dialog" or role="img" appropriately.

The A11Y Project provides excellent checklists for modal dialogs.

Performance and Image Optimization

Lightbox galleries can become sluggish if large images are loaded on demand. Apply these best practices:

  • Resize full‑resolution images – Serve images at a maximum width of 1920px (or 1600px for typical screens). Tools like sharp or CMS‑side image processing can generate appropriate sizes.
  • Use modern formats – WebP and AVIF offer superior compression. Provide fallback formats via <picture> or accept‑header negotiation.
  • Lazy load thumbnails – Already included via the loading="lazy" attribute.
  • Caching – Set far‑future cache headers for image assets; many CDNs do this automatically.
  • Preload next image – When navigation arrows are used, preload the adjacent image by adding a hidden <link rel="preload"> or using the Image constructor:
function preloadImage(src) {
  const img = new Image();
  img.src = src;
}

// After showImage, call preload for next/prev
const nextSrc = links[(currentIndex + 1) % links.length].href;
preloadImage(nextSrc);
const prevSrc = links[(currentIndex - 1 + links.length) % links.length].href;
preloadImage(prevSrc);

Integrating with a Backend (e.g., Directus)

If you are using a headless CMS like Directus, you can dynamically generate the gallery markup from the API. For example, fetch the images array and create the <a> elements in JavaScript:

async function loadGallery() {
  const response = await fetch('https://cms.example.com/items/photos');
  const data = await response.json();
  const galleryEl = document.querySelector('.gallery');
  data.data.forEach(photo => {
    const link = document.createElement('a');
    link.href = photo.full_url;
    link.className = 'gallery__link';
    link.dataset.caption = photo.caption;
    const img = document.createElement('img');
    img.src = photo.thumbnail_url;
    img.alt = photo.alt_text;
    img.width = 300;
    img.height = 200;
    img.loading = 'lazy';
    link.appendChild(img);
    galleryEl.appendChild(link);
  });
  // Re‑initialize lights after DOM update (event delegation already works)
}

This keeps your gallery in sync with the CMS without hard‑coding thumbnails.

Complete CSS Animations and Transitions

To make the lightbox feel polished, add a subtle scale animation to the image when it appears:

.lightbox.active .lightbox__image {
  animation: lightboxFadeIn 0.3s ease both;
}
@keyframes lightboxFadeIn {
  from { opacity: 0; transform: scale(0.95); }
  to   { opacity: 1; transform: scale(1); }
}

You can also animate the background overlay – the transition on opacity we set earlier already handles that.

Testing and Browser Support

  • Test across Chrome, Firefox, Safari, Edge, and iOS Safari.
  • Verify that the overlay works correctly in full‑screen mode.
  • Check that the focus trap doesn’t break on tab navigation.
  • Use the Firefox Accessibility Inspector or similar tools to validate roles and labels.

Conclusion

Building a custom JavaScript lightbox gallery is a rewarding exercise that teaches you DOM manipulation, event handling, CSS transitions, and accessibility patterns. The implementation shown here is modular, performant, and easy to extend – whether you add video support, swipe gestures, or integration with a headless CMS like Directus. By avoiding unnecessary dependencies, you keep your codebase lightweight and your user experience under your full control.