Dynamic Photo Gallery: HTML, CSS, and Lightbox

Spread the love

Introduction

This tutorial shows you how to create a dynamic photo gallery using semantic HTML, modern CSS Grid, and a minimal JavaScript Lightbox. You’ll learn how to structure your markup, style a flexible grid, wire up the Lightbox for keyboard and touch, and ship an accessible, responsive component you can reuse in any project. The approach favors progressive enhancement: the gallery is fully usable without JavaScript, and the Lightbox adds a polished overlay experience for keyboard, mouse, and touch users.

dynamic photo gallery image
dynamic photo gallery image

What You’ll Build and Why It Works

We’ll assemble a production-ready dynamic photo gallery that uses:

  • Semantic HTML (, ) for meaning, SEO, and captions that travel with their images.
  • CSS Grid for a responsive layout that adapts without media query bloat, using auto-fit/minmax() and fluid gaps.
  • A tiny Lightbox in vanilla JavaScript that supports keyboard navigation, ARIA attributes, and a simple focus trap.
  • Performance techniques like WebP, lazy-loading, decoding="async", preloading critical CSS, and content-visibility.

Key Features

  • Auto-wrapping grid with even spacing and predictable aspect ratios.
  • Click or tap any image to open the Lightbox overlay.
  • Arrow keys for previous/next; Esc to close; tab order protected.
  • Focus management for accessibility and clear ARIA semantics.
  • Progressive enhancement: core gallery works even if JS is off.

Project Structure and Starter HTML

Create a lightweight HTML page. The dynamic photo gallery will live in a with headings and accessible labels. You can swap the image paths for your own assets.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Aurora Gallery — Immersive Photo Grid & Lightbox</title>
  <link rel="stylesheet" href="css/styles.css">
</head>
<body>
  <header>
    <h1>Aurora Gallery</h1>
    <p>Experience a cinematic collection of landscapes and cityscapes curated for immersive viewing. Click any frame to launch the lightbox, navigate with arrows, and explore every detail.</p>
  </header>

  <div class="gallery-shell">
    <div class="gallery-grid" id="gallery">
      
      <figure class="wide" data-label="Northern Dawn • Iceland" data-full="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1800&q=85">
        <img src="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=80" alt="Sunrise casting warm light over snowy mountains.">
      </figure>
      <figure class="tall" data-label="Ocean Cliffside • Portugal" data-full="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=800&q=80" alt="Waves hitting dramatic cliffs along the coast.">
      </figure>
      <figure data-label="Misty Pines • British Columbia" data-full="https://images.unsplash.com/photo-1501959915551-4e8d30928317?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1501959915551-4e8d30928317?auto=format&fit=crop&w=800&q=80" alt="Dense forest with mist rolling through tall pine trees.">
      </figure>
      <figure data-label="Lupine Valley • New Zealand" data-full="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=800&q=80" alt="Purple lupine field leading towards distant mountains.">
      </figure>
      <figure class="tall" data-label="Glacial Mirror • Patagonia" data-full="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1441974231531-c6227db76b6e?auto=format&fit=crop&w=800&q=80" alt="Glacial lake reflecting rugged mountain range.">
      </figure>
      <figure data-label="Golden Steppe • Mongolia" data-full="https://images.unsplash.com/photo-1528543606781-2f6e6857f318?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1528543606781-2f6e6857f318?auto=format&fit=crop&w=800&q=80" alt="Rolling golden hills under a soft twilight sky.">
      </figure>
      <figure data-label="Aurora Borealis • Norway" data-full="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=800&q=80" alt="Northern lights dancing above a snowy landscape.">
      </figure>
      <figure class="wide" data-label="Lavender Dusk • Provence" data-full="https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=1800&q=85">
        <img src="https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=900&q=80" alt="Lavender fields stretching to the horizon at sunset.">
      </figure>
      <figure data-label="Desert Glow • Namibia" data-full="https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=800&q=80" alt="Sand dunes bathed in golden evening light.">
      </figure>
      <figure data-label="Canyon River • Utah" data-full="https://images.unsplash.com/photo-1501785888041-af3ef285b472?auto=format&fit=crop&w=1600&q=85">
        <img src="https://images.unsplash.com/photo-1489515217757-5fd1be406fef?auto=format&fit=crop&w=900&q=80" alt="River winding through dramatic canyon walls.">
      </figure>
      <figure data-label="Twilight Eyes• Swiss Alps" data-full="https://images.unsplash.com/photo-1500534314209-a25ddb2bd425?auto=format&fit=crop&w=1600&q=85">
        <img src="https://media.istockphoto.com/id/814423752/photo/eye-of-model-with-colorful-art-make-up-close-up.jpg?s=612x612&w=0&k=20&c=l15OdMWjgCKycMMShP8UK94ELVlEGvt7GmB_esHWPYE=" alt="Snow-capped peaks illuminated by twilight hues.">
      </figure>
    </div>
  </div>

  <div class="lightbox" aria-hidden="true" role="dialog" aria-modal="true">
    <div class="lightbox-inner">
      <button class="lightbox-close" type="button" aria-label="Close lightbox">&times;</button>
      <img src="" alt="">
      <div class="lightbox-caption"></div>
      <button class="lightbox-prev" type="button" aria-label="Previous photo">&#10094;</button>
      <button class="lightbox-next" type="button" aria-label="Next photo">&#10095;</button>
    </div>
  </div>
  <script src="js/lightbox.js"></script>
</body>
</html>

Accessibility tip: the dialog uses role=”dialog”, aria-modal=”true”, and aria-hidden. We’ll toggle aria-hidden and trap focus when the dynamic photo gallery Lightbox opens.

Styling the Responsive Grid with CSS

We’ll use CSS Grid to make the dynamic photo gallery flexible without hard breakpoints. The grid auto-fits cards, while content-visibility improves performance.

:root {
      --bg: #030712;
      --bg-gradient: radial-gradient(circle at 10% 20%, rgba(37, 99, 235, 0.36), transparent 45%),
                     radial-gradient(circle at 80% 10%, rgba(124, 58, 237, 0.35), transparent 42%),
                     var(--bg);
      --text: #f8fafc;
      --muted: #94a3b8;
      --card-radius: 22px;
      --shadow-lg: 0 40px 120px rgba(15, 23, 42, 0.6);
      --shadow-sm: 0 20px 40px rgba(15, 23, 42, 0.45);
      --accent: rgba(148, 163, 184, 0.18);
      --glass: rgba(15, 23, 42, 0.55);
      --glass-strong: rgba(15, 23, 42, 0.72);
    }

    *,
    *::before,
    *::after {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      font-size: 16px;
      line-height: 1.6;
      color: var(--text);
      background: var(--bg-gradient);
      -webkit-font-smoothing: antialiased;
      min-height: 100vh;
    }

    a {
      color: inherit;
      text-decoration: none;
    }

    img {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: cover;
      border-radius: inherit;
    }

    header {
      padding: clamp(32px, 6vw, 56px);
      text-align: center;
      max-width: 960px;
      margin: 0 auto;
    }

    header h1 {
      margin: 0 0 18px;
      font-size: clamp(2.4rem, 6vw, 3.8rem);
      font-weight: 700;
      letter-spacing: -0.03em;
    }

    header p {
      margin: 0;
      color: var(--muted);
      font-size: clamp(1rem, 2.5vw, 1.12rem);
    }

    .gallery-shell {
      max-width: 1220px;
      margin: clamp(20px, 5vw, 60px) auto 120px;
      padding: clamp(18px, 4vw, 30px);
      background: rgba(15, 23, 42, 0.4);
      border-radius: 40px;
      border: 1px solid rgba(148, 163, 184, 0.12);
      backdrop-filter: blur(24px);
      box-shadow: 0 45px 120px rgba(6, 10, 27, 0.65);
    }

    .gallery-grid {
      position: relative;
      display: grid;
      grid-template-columns: repeat(1, minmax(0, 1fr));
      gap: clamp(10px, 1.6vw, 22px);
      grid-auto-rows: clamp(180px, 54vw, 260px);
    }

    figure {
      position: relative;
      margin: 0;
      border-radius: var(--card-radius);
      overflow: hidden;
      min-height: 100%;
      --row-span: 1;
      --col-span: 1;
      grid-column: span 1;
      grid-row: span var(--row-span);
      background: var(--glass);
      border: 1px solid rgba(148, 163, 184, 0.14);
      box-shadow: var(--shadow-sm);
      transition: transform 0.35s ease, box-shadow 0.35s ease, filter 0.35s ease;
      isolation: isolate;
      cursor: pointer;
    }

    figure::after {
      content: attr(data-label);
      position: absolute;
      inset: auto 18px 18px;
      background: var(--glass-strong);
      padding: 8px 14px;
      border-radius: 999px;
      font-size: 0.86rem;
      letter-spacing: 0.04em;
      color: var(--muted);
      backdrop-filter: blur(14px);
      opacity: 0;
      transform: translateY(18px);
      transition: opacity 0.3s ease, transform 0.3s ease;
    }

    figure:hover,
    figure:focus-visible {
      transform: translateY(-8px) scale(1.01);
      box-shadow: var(--shadow-lg);
      filter: saturate(1.12);
    }

    figure:hover::after,
    figure:focus-visible::after {
      opacity: 1;
      transform: translateY(0);
    }

    .feature { --row-span: 1; --col-span: 1; }
    .tall { --row-span: 1; }
    .wide { --col-span: 1; }

    @media (min-width: 640px) {
      .gallery-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        grid-auto-rows: clamp(200px, 34vw, 280px);
      }

      figure {
        grid-column: span var(--col-span);
      }

      .tall {
        --row-span: 2;
      }

      .wide {
        --col-span: 2;
      }

      .feature {
        --col-span: 2;
        --row-span: 2;
      }
    }

    @media (min-width: 1024px) {
      .gallery-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));
        grid-auto-rows: clamp(220px, 22vw, 260px);
      }

      .wide {
        --col-span: 2;
      }

      .feature {
        --col-span: 2;
        --row-span: 2;
      }
    }

    @media (max-width: 680px) {
      header {
        padding: 46px 24px 16px;
      }
      .gallery-shell {
        border-radius: 28px;
        padding: 22px;
      }
    }

    /* Lightbox */
    .lightbox {
      position: fixed;
      inset: 0;
      display: grid;
      place-items: center;
      background: rgba(3, 7, 18, 0.85);
      backdrop-filter: blur(16px);
      z-index: 100;
      padding: clamp(20px, 4vw, 60px);
      visibility: hidden;
      opacity: 0;
      transition: opacity 0.3s ease;
    }

    .lightbox[aria-hidden="false"] {
      visibility: visible;
      opacity: 1;
    }

    .lightbox-inner {
      position: relative;
      max-width: min(1080px, 90vw);
      width: 100%;
      border-radius: 32px;
      overflow: hidden;
      background: rgba(15, 23, 42, 0.88);
      border: 1px solid rgba(148, 163, 184, 0.25);
      box-shadow: 0 35px 120px rgba(3, 7, 18, 0.65);
    }

    .lightbox img {
      display: block;
      width: 100%;
      max-height: min(72vh, 720px);
      object-fit: cover;
      border-radius: inherit;
    }

    .lightbox-caption {
      padding: 20px 26px 24px;
      color: var(--muted);
      font-size: 0.98rem;
      letter-spacing: 0.02em;
    }

    .lightbox button {
      position: absolute;
      top: 50%;
      transform: translateY(-50%);
      background: rgba(15, 23, 42, 0.75);
      border: 1px solid rgba(148, 163, 184, 0.3);
      color: var(--text);
      width: 46px;
      height: 46px;
      border-radius: 50%;
      display: grid;
      place-items: center;
      font-size: 1.4rem;
      cursor: pointer;
      transition: transform 0.25s ease, background 0.25s ease;
    }

    .lightbox button:hover {
      transform: translateY(-50%) scale(1.05);
      background: rgba(37, 99, 235, 0.25);
    }

    .lightbox-close {
      top: clamp(12px, 2vw, 16px);
      right: clamp(12px, 2vw, 16px);
      transform: none;
      width: 42px;
      height: 42px;
      font-size: 1.15rem;
    }

    .lightbox-prev { left: clamp(12px, 2.5vw, 20px); }
    .lightbox-next { right: clamp(12px, 2.5vw, 20px); }

    @media (max-width: 640px) {
      .lightbox button {
        display: none;
      }
      .lightbox-close {
        display: grid;
      }
    }

    @media (prefers-reduced-motion: reduce) {
      *,
      *::before,
      *::after {
        animation: none !important;
        transition: none !important;
      }
    }

Lightbox JavaScript with A11y in Mind

The script wires up opening, closing, image swapping, focus control, and swipe gestures. It is framework-free vanilla JS.

// /js/lightbox.js
(() => {
  const triggers = [...document.querySelectorAll('.lightbox-trigger')];
  const lb = document.querySelector('.lightbox');
  if (!lb || !triggers.length) return;

  const lbImg = lb.querySelector('.lightbox-image');
  const lbCaption = lb.querySelector('.lightbox-caption');
  const btnClose = lb.querySelector('.lightbox-close');
  const btnPrev = lb.querySelector('.lightbox-prev');
  const btnNext = lb.querySelector('.lightbox-next');
  const backdrop = lb.querySelector('.lightbox-backdrop');
  const focusables = [btnClose, btnPrev, btnNext];

  let currentIndex = 0;
  let lastActive = null;

  // Touch swipe support
  let touchStartX = 0;
  let touchEndX = 0;

  function open(index) {
    currentIndex = index;
    lastActive = document.activeElement;
    update();
    lb.setAttribute('aria-hidden', 'false');
    document.body.style.overflow = 'hidden';
    document.querySelector('main')?.setAttribute('inert', '');
    btnClose.focus();
    document.addEventListener('keydown', onKey);
  }

  function close() {
    lb.setAttribute('aria-hidden', 'true');
    document.body.style.overflow = '';
    document.removeEventListener('keydown', onKey);
    document.querySelector('main')?.removeAttribute('inert');
    if (lastActive) lastActive.focus();
  }

  function onKey(e) {
    if (e.key === 'Escape') return close();
    if (e.key === 'ArrowRight') return next();
    if (e.key === 'ArrowLeft') return prev();
    if (e.key === 'Home') { currentIndex = 0; return update(); }
    if (e.key === 'End') { currentIndex = triggers.length - 1; return update(); }
    if (e.key === 'Tab') {
      const dir = e.shiftKey ? -1 : 1;
      const i = (focusables.indexOf(document.activeElement) + dir + focusables.length) % focusables.length;
      focusables[i].focus();
      e.preventDefault();
    }
  }

  function update() {
    const link = triggers[currentIndex];
    const href = link.getAttribute('href');
    const fig = link.closest('figure');
    const caption = fig?.querySelector('figcaption')?.textContent?.trim() || 'Photo';
    lbImg.src = href;
    lbImg.alt = caption;
    lbCaption.textContent = caption;
    // Preload neighbors
    const nextIdx = (currentIndex + 1) % triggers.length;
    const prevIdx = (currentIndex - 1 + triggers.length) % triggers.length;
    [nextIdx, prevIdx].forEach(i => {
      const img = new Image();
      img.src = triggers[i].getAttribute('href');
    });
  }

  function next() {
    currentIndex = (currentIndex + 1) % triggers.length;
    update();
  }

  function prev() {
    currentIndex = (currentIndex - 1 + triggers.length) % triggers.length;
    update();
  }

  // Open on click
  triggers.forEach(t => {
    t.addEventListener('click', e => {
      e.preventDefault();
      const idx = Number(t.dataset.index);
      if (!Number.isNaN(idx)) open(idx);
    });
  });

  // Close & navigate
  [btnClose, backdrop].forEach(el => el.addEventListener('click', close));
  btnNext.addEventListener('click', next);
  btnPrev.addEventListener('click', prev);

  // Touch gestures
  lb.addEventListener('touchstart', e => {
    touchStartX = e.changedTouches[0].clientX;
  }, { passive: true });

  lb.addEventListener('touchend', e => {
    touchEndX = e.changedTouches[0].clientX;
    const dx = touchEndX - touchStartX;
    if (Math.abs(dx) > 40) dx < 0 ? next() : prev();
  }, { passive: true });

})();

Result

photo gallery screenshot
photo gallery screenshot

For Source Code

Visit Github : https://github.com/ritesh-0309/Dynamic-Photo-Gallery

Performance and Image Strategy

To keep the dynamic photo gallery fast:

  • Serve WebP (and AVIF with fallbacks).
  • Set precise width/height to reduce CLS.
  • Use loading=”lazy” and decoding=”async” on thumbnails.
  • Preload critical CSS; defer JS.
  • Use srcset/sizes for responsive thumbnails.

Accessibility Checklist

  • Meaningful alt text for every image.
  • Lightbox container has role=”dialog”, aria-modal=”true”; toggle aria-hidden.
  • Move focus into the dialog on open; return it to the trigger on close.
  • Support Esc, ArrowLeft/ArrowRight, and optionally Home/End.
  • Preserve DOM order to match reading order.
  • Ensure visible focus states on prev/next/close buttons.
  • Honor prefers-reduced-motion.

Optional Enhancements

Captions and Credits

Add byline links below captions for photographer credit; keep them concise and accessible.

Keyboard Shortcuts

Extend shortcuts (e.g., P/N keys) and document them via an on-screen hint or aria-describedby.

Theming

Expose CSS custom properties (--bg, --text, --accent) and toggle a data-theme attribute for light/dark modes.

RTL Support

Test with dir=”rtl”. Confirm caption alignment and swipe direction expectations.

Hash/Deep Linking

Optionally push a hash (e.g., #photo-03) when opening; on hashchange, open the correct slide.

Skeleton Loading

Show a subtle skeleton or background while large images load; fade in when ready.


Testing and Troubleshooting

  • Keyboard-only: Tab to a thumb, open, navigate, close, and verify focus returns.
  • Screen readers: Dialog announces correctly; alt text reads on slide change.
  • Mobile: Swipe works; no background scroll; buttons are easy to tap.
  • Performance: Check CLS/LCP with Lighthouse; verify responsive images.
  • Edge cases: Very tall/wide images; long captions; extremely narrow viewports.

Scaling Up: Dynamic Data and CMS Integration

In real projects, gallery items likely come from a CMS or API. Generate cards server-side, or hydrate client-side with JSON. Keep the semantic pattern—each image is a link to its large version, followed by a meaningful . Store fields for large and thumbnail URLs, width/height, alt text, and optional credit/byline. This preserves performance and accessibility as the gallery scales.

For editorial or e-commerce, you can add badges, prices, or tags within the figure (or a sibling container). Keep interactive icons keyboard-accessible with visible focus and descriptive labels.

Conclusion and Next Steps

You now have a reusable dynamic photo gallery powered by HTML, CSS Grid, and a tiny Lightbox script. It loads quickly, degrades gracefully, and respects accessibility from the start. Drop it into your portfolio, blog, or e-commerce project, and extend features as you go—filters, infinite scroll, deep linking, or captions synced to EXIF metadata are natural next steps.

Call to Action: If you enjoyed this guide, subscribe for weekly front-end tips, or watch our CSS Grid and accessibility series for hands-on demos. Have a feature idea you want next? Comment below and we’ll build it together.


Spread the love

Leave a Reply

Your email address will not be published. Required fields are marked *