webdevpuneet.com

Photo Gallery with Lightbox — Free HTML CSS JS Snippet

Photo Gallery with Lightbox · Media · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<div class="gallery-page">
  <h1 class="page-title">Travel Photography</h1>
  <p class="page-sub">Click any photo to view full size</p>

  <div class="gallery" id="gallery">
    <div class="item" data-src="https://picsum.photos/seed/mtn/800/600" data-alt="Mountain peaks at sunrise" data-cap="Mountain peaks at sunrise — Dolomites, Italy">
      <img src="https://picsum.photos/seed/mtn/400/300" alt="Mountain peaks at sunrise" loading="lazy" />
      <div class="overlay">
        <svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
      </div>
    </div>
    <div class="item" data-src="https://picsum.photos/seed/sea/800/600" data-alt="Coastal sunset" data-cap="Golden hour on the Amalfi Coast — Italy">
      <img src="https://picsum.photos/seed/sea/400/300" alt="Coastal sunset" loading="lazy" />
      <div class="overlay"><svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
    </div>
    <div class="item item-wide" data-src="https://picsum.photos/seed/forest/1200/600" data-alt="Forest path in autumn" data-cap="Autumn light through beech forest — Bavaria, Germany">
      <img src="https://picsum.photos/seed/forest/600/300" alt="Forest path in autumn" loading="lazy" />
      <div class="overlay"><svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
    </div>
    <div class="item" data-src="https://picsum.photos/seed/city/800/600" data-alt="City skyline at night" data-cap="Night skyline — Tokyo, Japan">
      <img src="https://picsum.photos/seed/city/400/300" alt="City skyline at night" loading="lazy" />
      <div class="overlay"><svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
    </div>
    <div class="item" data-src="https://picsum.photos/seed/desert/800/600" data-alt="Desert sand dunes" data-cap="Sahara dunes at dawn — Morocco">
      <img src="https://picsum.photos/seed/desert/400/300" alt="Desert sand dunes" loading="lazy" />
      <div class="overlay"><svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
    </div>
    <div class="item item-tall" data-src="https://picsum.photos/seed/waterfall/600/900" data-alt="Waterfall cascade" data-cap="Angel Falls cascade — Venezuela">
      <img src="https://picsum.photos/seed/waterfall/300/450" alt="Waterfall cascade" loading="lazy" />
      <div class="overlay"><svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
    </div>
    <div class="item" data-src="https://picsum.photos/seed/lake/800/600" data-alt="Alpine lake reflection" data-cap="Mirror lake at golden hour — Switzerland">
      <img src="https://picsum.photos/seed/lake/400/300" alt="Alpine lake reflection" loading="lazy" />
      <div class="overlay"><svg class="zoom-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></div>
    </div>
  </div>

  <!-- lightbox -->
  <div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Photo lightbox" hidden>
    <div class="lb-backdrop" id="lb-backdrop"></div>
    <button class="lb-close" id="lb-close" aria-label="Close lightbox">
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
    </button>
    <button class="lb-arrow lb-prev" id="lb-prev" aria-label="Previous photo">
      <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
    </button>
    <div class="lb-content">
      <img class="lb-img" id="lb-img" src="" alt="" />
      <p class="lb-caption" id="lb-caption"></p>
      <span class="lb-counter" id="lb-counter"></span>
    </div>
    <button class="lb-arrow lb-next" id="lb-next" aria-label="Next photo">
      <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
    </button>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

CSS grid with spanning cells
.item-wide spans 2 columns and .item-tall spans 2 rows — creating an editorial mosaic layout without JavaScript or a third-party masonry library.
Hover overlay + zoom animation
Each item has a semi-transparent overlay that fades in on hover, with a zoom icon that scales from 70% to 100% and the image that scales to 106%.
Full-screen lightbox
Modal overlay with position:fixed; inset:0 and backdrop:rgba(0,0,0,.92). The photo fades in from scale 0.95 using a @keyframes lbIn animation.
Keyboard navigation
Escape closes the lightbox; ArrowLeft/ArrowRight navigate. Focus is trapped on open and returned to the trigger thumbnail on close.
Touch swipe support
Passive touchstart + touchend listeners detect left/right swipes with a 50px threshold — no touch library needed.
Caption and counter
Caption text from data-cap and a position counter ("1 / 7") display below the lightbox image. Both update instantly on navigation.
Lazy loading thumbnails
All <img> elements have loading="lazy" — the browser defers loading off-screen thumbnails until they're about to scroll into view.
Responsive breakpoint
At ≤600px, the grid switches to 2 columns with a shorter row height. Wide items still span 2 columns to maintain the editorial feel on mobile.

About this UI Snippet

Photo Gallery with Lightbox — CSS Grid Layout, Hover Overlays & Keyboard-Navigable Lightbox in Vanilla JS

Screenshot of the Photo Gallery with Lightbox snippet rendered live

A professional photo gallery needs two layers: a compact grid that shows thumbnails at a glance, and a full-screen lightbox for focused viewing. This snippet delivers both — a CSS grid with wide and tall spanning items, smooth hover overlays with a zoom-in icon, and a full-screen lightbox with fade-in animation, prev/next arrows, keyboard navigation (Escape, arrow keys), touch swipe, caption display, and position counter — all in pure HTML, CSS, and vanilla JavaScript.

A well-built photo gallery is one of the most complex layout challenges in UI development: it must present many images compactly without reflow jank, handle images of different aspect ratios gracefully, provide a fast path to full-screen viewing, and remain accessible on both desktop and mobile. This snippet covers the complete stack — CSS grid layout, hover overlays, full-screen lightbox, keyboard navigation, swipe gestures, and lazy loading — in pure HTML, CSS, and vanilla JavaScript with no dependencies.

CSS grid with spanning items

The gallery uses display: grid with grid-template-columns: repeat(3, 1fr) and grid-auto-rows: 200px. Items span multiple columns or rows using .item-wide { grid-column: span 2 } and .item-tall { grid-row: span 2 }. This produces a masonry-like layout where feature images can be wider or taller than thumbnails without JavaScript. The key technique for making images fill their grid cell regardless of intrinsic dimensions is width: 100%; height: 100%; object-fit: cover on the <img> element — cover scales the image to fill the container while cropping rather than stretching or leaving whitespace.

Hover overlay animation

Each grid item has a <div class="overlay"> absolutely positioned over the image. On hover, the overlay's background transitions from rgba(0,0,0,0) to rgba(0,0,0,0.45) and a zoom icon SVG transitions from opacity:0; transform:scale(0.7) to opacity:1; transform:scale(1). Simultaneously, the image itself scales up to scale(1.06) via a transform on img:hover, but because overflow:hidden is set on the parent .item, the scaled image stays within the card boundary — the zoom-in feels like you're pushing through the frame.

Lightbox open/close and focus management

Clicking a gallery item calls openLightbox(index) which sets lb.hidden = false, locks the scroll with document.body.style.overflow = 'hidden', and moves keyboard focus to the close button. Closing calls closeLightbox() which sets lb.hidden = true, unlocks scroll, and returns focus to the thumbnail that was clicked (items[current].querySelector('img').focus()). This focus-return pattern is required for screen reader compatibility — without it, keyboard users lose their place in the document after closing the modal.

Photo switching and animation restart

showPhoto() updates lbImg.src, lbImg.alt, the caption, and the counter. To restart the fade-in animation on each photo change, it sets lbImg.style.animation = 'none', forces a reflow with void lbImg.offsetWidth (a sync layout read that flushes the pending style), then clears the inline animation style inside a requestAnimationFrame — this causes the browser to re-apply the keyframe from the stylesheet on the next frame. Without the forced reflow, the browser batches the style change and the animation doesn't restart.

Keyboard and touch navigation

A document.keydown handler checks whether the lightbox is open before acting: Escape closes, ArrowLeft decrements current, ArrowRight increments it. Touch navigation uses touchstart (passive listener for scroll performance) to record startX, and touchend to compute dx. A 50px threshold filters accidental taps.

Step by step

How to Use

  1. 1
    Load the snippetPaste the HTML, CSS, and JS into your page. A 3-column dark grid appears with photos from Picsum — wide and tall items create an editorial mosaic layout.
  2. 2
    Hover over any photoA semi-transparent dark overlay fades in with a magnifier "+" icon. The photo simultaneously scales up slightly, constrained by the cell's overflow:hidden.
  3. 3
    Click to open the lightboxThe lightbox opens full-screen with the photo fading in at scale. Caption and position counter ("2 / 7") appear below the image.
  4. 4
    Navigate with arrows or keyboardClick the ← / → arrows or press the keyboard arrow keys to move between photos. The Escape key closes the lightbox.
  5. 5
    Swipe on mobileTouch-swipe left or right in the lightbox to navigate between photos. A 50px threshold prevents accidental swipes during scrolling.
  6. 6
    Swap in your own imagesReplace each data-src (full image URL), src (thumbnail URL), alt, and data-cap attributes on the .item divs. Add or remove .item divs to change the gallery size.

Real-world uses

Common Use Cases

Portfolio and photography sites
Display project screenshots or photos in a masonry grid with a lightbox for full-resolution viewing. Pair with a hero section above for a personal site.
Product image galleries
E-commerce product detail pages can use the grid for alternate angles and a standalone image lightbox for zoom-in inspection. Add an image magnifier for hover-to-zoom on fabric and texture detail.
Event recap pages
Showcase conference, wedding, or party photos in an editorial grid with captions. Wide cells can highlight key moments with a panoramic photo.
Real estate listings
Property listings can display room photos in the grid with the lightbox for full-screen viewing. Caption each photo with the room name.
Blog post embedded galleries
Embed a compact grid inside a long-form article for series of screenshots or diagrams, with lightbox access for readers who want to inspect details.
Team or client showcase
Display headshots or brand logos in a uniform grid. Clicking opens a larger image with name and role as the caption text.
Related: Smart TV Mockup
See the Smart TV Mockup for a related layouts pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Add a .item div for each photo with data-src (full URL), src (thumbnail), alt, and data-cap attributes. Copy the overlay SVG from an existing item. Add .item-wide or .item-tall for spanning cells. The JS uses querySelectorAll(".gallery .item") so new items are picked up automatically.

Replace the src and data-src attributes with your own image paths. Thumbnails can be the same image as the full version — object-fit:cover crops them to the grid cell size. For production, generate separate thumbnail sizes to reduce load time.

Remove the .item-wide and .item-tall classes from the HTML and their CSS rules. Change grid-template-columns to repeat(auto-fill, minmax(200px,1fr)) for a fully responsive equal-height grid.

Yes. Use the JSX, Vue, Angular, or Tailwind export buttons on this page. In React, keep currentIndex and lightboxOpen in useState. Render the lightbox conditionally with a portal (ReactDOM.createPortal) so it sits outside the gallery DOM tree. Attach keyboard listeners in useEffect with cleanup. In Vue, use v-if on the lightbox and handle keyboard events in mounted/beforeUnmount. In Angular, use *ngIf and @HostListener.

Setting animation: none then immediately removing it in the same JS tick doesn't restart the animation because the browser batches style changes. Reading offsetWidth forces a synchronous layout flush, committing the animation:none state before the next style is applied. The requestAnimationFrame callback then removes the inline style, letting the CSS animation re-apply from frame 0.