An image gallery with a lightbox overlay lets visitors browse photos at full size without leaving the page. Here is how to build one from scratch.
Step 1: Define Gallery Data
type GalleryImage = {
src: string;
alt: string;
width: number;
height: number;
};
const images: GalleryImage[] = [
{ src: "/images/gallery/project-1.jpg", alt: "E-commerce website redesign", width: 1200, height: 800 },
{ src: "/images/gallery/project-2.jpg", alt: "Restaurant mobile app", width: 800, height: 1200 },
{ src: "/images/gallery/project-3.jpg", alt: "SaaS dashboard design", width: 1200, height: 800 },
{ src: "/images/gallery/project-4.jpg", alt: "Real estate listing page", width: 1200, height: 800 },
{ src: "/images/gallery/project-5.jpg", alt: "Fitness app interface", width: 800, height: 1200 },
{ src: "/images/gallery/project-6.jpg", alt: "Corporate website", width: 1200, height: 800 },
];
Step 2: Build the Masonry Grid
import Image from "next/image";
function GalleryGrid({
images,
onSelect,
}: {
images: GalleryImage[];
onSelect: (index: number) => void;
}) {
return (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{images.map((image, index) => (
<button
key={image.src}
onClick={() => onSelect(index)}
className="mb-4 block w-full overflow-hidden rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<Image
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
className="w-full transition-transform duration-300 hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</button>
))}
</div>
);
}
Step 3: Build the Lightbox Overlay
"use client";
import { useEffect, useCallback } from "react";
import Image from "next/image";
function Lightbox({
images,
currentIndex,
onClose,
onPrev,
onNext,
}: {
images: GalleryImage[];
currentIndex: number;
onClose: () => void;
onPrev: () => void;
onNext: () => void;
}) {
const image = images[currentIndex];
// Keyboard navigation
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") onPrev();
if (e.key === "ArrowRight") onNext();
},
[onClose, onPrev, onNext]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [handleKeyDown]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
role="dialog"
aria-label="Image lightbox"
aria-modal="true"
>
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 z-10 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
aria-label="Close lightbox"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Previous button */}
<button
onClick={onPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
aria-label="Previous image"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Image */}
<div className="relative max-h-[85vh] max-w-[90vw]">
<Image
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
className="max-h-[85vh] w-auto object-contain"
priority
/>
</div>
{/* Next button */}
<button
onClick={onNext}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
aria-label="Next image"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Caption and counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-center text-white">
<p className="text-sm">{image.alt}</p>
<p className="mt-1 text-xs text-white/60">
{currentIndex + 1} / {images.length}
</p>
</div>
</div>
);
}
Step 4: Add Touch Swipe Support
import { useRef } from "react";
function useTouchSwipe(onSwipeLeft: () => void, onSwipeRight: () => void) {
const touchStartX = useRef(0);
const touchEndX = useRef(0);
function handleTouchStart(e: React.TouchEvent) {
touchStartX.current = e.targetTouches[0].clientX;
}
function handleTouchMove(e: React.TouchEvent) {
touchEndX.current = e.targetTouches[0].clientX;
}
function handleTouchEnd() {
const diff = touchStartX.current - touchEndX.current;
const threshold = 50;
if (diff > threshold) {
onSwipeLeft(); // Swiped left → next
} else if (diff < -threshold) {
onSwipeRight(); // Swiped right → prev
}
}
return { handleTouchStart, handleTouchMove, handleTouchEnd };
}
Add the handlers to the lightbox container:
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useTouchSwipe(onNext, onPrev);
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="fixed inset-0 z-50 ..."
>
Step 5: Assemble the Gallery Component
"use client";
import { useState } from "react";
export function ImageGallery({ images }: { images: GalleryImage[] }) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
function handlePrev() {
setLightboxIndex((prev) =>
prev !== null ? (prev - 1 + images.length) % images.length : null
);
}
function handleNext() {
setLightboxIndex((prev) =>
prev !== null ? (prev + 1) % images.length : null
);
}
return (
<div>
<GalleryGrid images={images} onSelect={setLightboxIndex} />
{lightboxIndex !== null && (
<Lightbox
images={images}
currentIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onPrev={handlePrev}
onNext={handleNext}
/>
)}
</div>
);
}
Step 6: Optimize Performance
- Use
next/imagefor automatic optimization and lazy loading - Provide
sizesattribute for responsive image loading - Use
priorityon the lightbox image for faster display - Generate thumbnails for the grid and load full-size only in the lightbox
- Consider blur placeholders with
placeholder="blur"
Accessibility
- Trap focus inside the lightbox when open
- Support keyboard navigation (Escape, Arrow keys)
- Use
role="dialog"andaria-modal="true" - Provide
aria-labelon all buttons - Restore scroll position when closing
- Ensure all images have descriptive
alttext
Need a Portfolio or Gallery Website?
We create image-rich websites for photographers, real estate agents, and creative businesses. Contact us to showcase your work.