Implementing Image Gallery (Lightbox) on Website
Gallery with lightbox is one of the most frequent UI components. Task seems simple until you deal with touch swipes, lazy loading, zoom, neighbor preloading, and accessibility.
Libraries
PhotoSwipe 5 — best option for most tasks. Clean vanilla JS, touch-first, zoom, srcset support. Actively maintained, ~20 KB gzip.
GLightbox — lightweight (~12 KB), supports video and iframe in lightbox, no dependencies.
Fancybox 5 — rich functionality, but paid for commercial projects.
For simple cases without zoom and video — GLightbox. For full photo gallery — PhotoSwipe.
PhotoSwipe: Integration
npm install photoswipe
import PhotoSwipeLightbox from 'photoswipe/lightbox'
import 'photoswipe/style.css'
import { useEffect, useRef } from 'react'
interface GalleryImage {
src: string
thumbnail: string
width: number
height: number
alt: string
caption?: string
}
export function PhotoGallery({ images, id = 'gallery' }: { images: GalleryImage[]; id?: string }) {
const galleryRef = useRef<HTMLElement>(null)
useEffect(() => {
if (!galleryRef.current) return
const lightbox = new PhotoSwipeLightbox({
gallery: `#${id}`,
children: 'a',
pswpModule: () => import('photoswipe'),
// Neighbor preloading
preload: [1, 2],
// Animation
showHideAnimationType: 'zoom',
// Close on background click
closeOnVerticalDrag: true,
// Zoom
maxZoomLevel: 4,
initialZoomLevel: 'fit',
secondaryZoomLevel: 1.5,
})
// Custom caption
lightbox.on('uiRegister', () => {
lightbox.pswp?.ui?.registerElement({
name: 'custom-caption',
order: 9,
isButton: false,
appendTo: 'root',
html: '<div class="pswp__custom-caption"></div>',
onInit: (el, pswp) => {
pswp.on('change', () => {
const currSlideElement = pswp.currSlide?.data.element
const caption = currSlideElement?.querySelector('figcaption')?.textContent ?? ''
el.querySelector('.pswp__custom-caption')!.textContent = caption
})
},
})
})
lightbox.init()
return () => lightbox.destroy()
}, [id, images])
return (
<section
id={id}
ref={galleryRef as any}
className="photo-gallery"
aria-label="Photo gallery"
>
{images.map((img, i) => (
<figure key={i} className="photo-gallery__item">
<a
href={img.src}
data-pswp-width={img.width}
data-pswp-height={img.height}
>
<img
src={img.thumbnail}
alt={img.alt}
loading="lazy"
decoding="async"
width={img.width}
height={img.height}
/>
</a>
{img.caption && <figcaption>{img.caption}</figcaption>}
</figure>
))}
</section>
)
}
Responsive Images via srcset
// Generate srcset for lightbox — different sizes for different screens
function buildSrcSet(baseUrl: string, sizes: number[]): string {
return sizes.map(w => `${baseUrl}?w=${w} ${w}w`).join(', ')
}
// In PhotoSwipe 5 — via dataSource
const dataSource = images.map(img => ({
src: img.src,
srcset: buildSrcSet(img.src, [800, 1200, 1920, 2560]),
width: img.width,
height: img.height,
alt: img.alt,
// For mobile — don't load 2560px
msrc: img.thumbnail, // placeholder until full load
}))
Masonry Grid Layout
/* CSS Columns — simplest masonry without JS */
.photo-gallery {
columns: 3 200px;
column-gap: 8px;
}
.photo-gallery__item {
break-inside: avoid;
margin-bottom: 8px;
}
.photo-gallery__item img {
width: 100%;
height: auto;
display: block;
}
@media (max-width: 768px) {
.photo-gallery { columns: 2 150px; }
}
@media (max-width: 480px) {
.photo-gallery { columns: 1; }
}
For CSS Masonry support (only Firefox with flag) or exact row alignment — Masonry.js or native CSS grid with grid-template-rows: masonry.
Uniform Grid with Known Dimensions
/* If aspect ratio is known in advance */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.gallery-grid__item {
aspect-ratio: 4/3;
overflow: hidden;
}
.gallery-grid__item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.gallery-grid__item:hover img {
transform: scale(1.05);
}
Lazy Loading with Placeholder
import { useState } from 'react'
function LazyGalleryImage({ src, thumbnail, alt, width, height }: GalleryImage) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
return (
<div className={`gallery-image ${loaded ? 'gallery-image--loaded' : ''}`}>
{/* LQIP (Low Quality Image Placeholder) */}
{!loaded && !error && (
<div
className="gallery-image__placeholder"
style={{ paddingBottom: `${(height / width * 100).toFixed(2)}%` }}
/>
)}
<img
src={thumbnail}
data-full-src={src}
alt={alt}
loading="lazy"
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
</div>
)
}
GLightbox for Video Gallery
npm install glightbox
import GLightbox from 'glightbox'
import 'glightbox/dist/css/glightbox.css'
const lightbox = GLightbox({
elements: [
{
href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
type: 'video',
source: 'youtube',
width: 900,
},
{
href: '/video/promo.mp4',
type: 'video',
description: 'Promotional video',
},
{
href: '/images/photo1.jpg',
type: 'image',
description: 'Photo description',
},
],
autoplayVideos: false,
loop: false,
draggable: true,
dragToleranceX: 40,
dragToleranceY: 65,
swipeToClose: true,
})
Keyboard Navigation and Accessibility
// PhotoSwipe supports keyboard natively
// Additionally: return focus when closing
lightbox.on('close', () => {
// Return focus to element from which we opened
const opener = document.querySelector('[data-gallery-opener]') as HTMLElement
opener?.focus()
})
// ARIA for grid
// role="list" + role="listitem" or just native <ul><li>
<ul class="photo-gallery" role="list" aria-label="Projects gallery">
<li role="listitem">
<a href="/large/1.jpg"
aria-label="Open image: Office Project, 2024"
data-pswp-width="2400"
data-pswp-height="1600">
<img src="/thumb/1.jpg" alt="Office Project, 2024" loading="lazy">
</a>
</li>
</ul>
Timeline
GLightbox with basic grid and CSS columns — half day. PhotoSwipe with masonry, srcset, caption, custom UI — 1.5–2 days. Full gallery with API loading, pagination, category filters, video support — 4–5 days.







