Building a Fast, Accessible React Image Gallery with a Lightbox
Build a fast, accessible React image gallery with a lightbox—keyboard nav, focus traps, preloading, and responsive images included.
Image used for representation purposes only.
Overview
A polished image gallery with a lightbox is a staple of modern product pages, portfolios, and editorial sites. In React, you can implement one that feels instant, works with keyboard and screen readers, and scales to thousands of images—without bloated dependencies. This article walks through building a fast, accessible React image gallery and lightbox from first principles, then layering in performance, animation, and framework-specific tips.
Goals and requirements
Before writing code, lock down the UX and a11y requirements:
- Keyboard support: open, close (Esc), previous/next (ArrowLeft/ArrowRight), Home/End to jump, Tab loops within the modal.
- Screen reader support: semantic buttons, alt text, ARIA roles/labels, focus management, background inertness.
- Performance: lazy thumbnails, decode hints, preloading adjacent images, responsive srcset/sizes, scroll locking.
- Touch/Pointer: swipe to navigate, click/tap outside to close, buttons sized for touch.
- Composability: separate gallery grid from lightbox; state managed via a small hook; easy to theme.
Designing the API
We’ll design three pieces:
- ImageItem: a typed data model for images
- GalleryGrid: renders responsive thumbnails, emits an index to open the lightbox
- Lightbox: full-viewport modal with navigation
export type ImageItem = {
id: string;
src: string; // large image
thumbSrc?: string; // thumbnail (optional)
width: number; // intrinsic width of large image
height: number; // intrinsic height of large image
alt: string; // meaningful alt text
srcSet?: string; // responsive candidates for large image
sizes?: string; // sizes for large image
thumbSrcSet?: string;
thumbSizes?: string;
};
Managing lightbox state with a hook
A tiny hook centralizes open/close and navigation logic.
import { useCallback, useEffect, useState } from 'react';
export function useLightbox(count: number) {
const [isOpen, setOpen] = useState(false);
const [index, setIndex] = useState<number>(0);
const open = useCallback((i: number) => { setIndex(i); setOpen(true); }, []);
const close = useCallback(() => setOpen(false), []);
const next = useCallback(() => setIndex((i) => (i + 1) % count), [count]);
const prev = useCallback(() => setIndex((i) => (i - 1 + count) % count), [count]);
return { isOpen, index, open, close, next, prev, setIndex };
}
The gallery grid
Use semantic buttons for thumbnails so they’re keyboard-focusable by default. Lazy-load aggressively.
import React from 'react';
import type { ImageItem } from './types';
export function GalleryGrid({ images, onOpen }: {
images: ImageItem[];
onOpen: (index: number) => void;
}) {
return (
<ul className="grid" role="list">
{images.map((img, i) => (
<li key={img.id} className="cell">
<button
type="button"
className="thumbBtn"
aria-label={`Open image ${i + 1} of ${images.length}`}
onClick={() => onOpen(i)}
>
<img
src={img.thumbSrc ?? img.src}
srcSet={img.thumbSrcSet}
sizes={img.thumbSizes}
alt={img.alt}
loading="lazy"
decoding="async"
/>
</button>
</li>
))}
</ul>
);
}
Minimal CSS to get started:
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.cell { list-style: none; }
.thumbBtn { display: block; padding: 0; border: 0; background: none; cursor: zoom-in; }
.thumbBtn img { width: 100%; height: auto; border-radius: 8px; }
Building the lightbox
We’ll render into a portal so the lightbox isn’t clipped by parent stacking contexts, and apply a focus trap. We’ll also lock page scroll while the lightbox is open.
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import type { ImageItem } from './types';
function useScrollLock(lock: boolean) {
useEffect(() => {
if (!lock) return;
const { overflow } = document.body.style;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = overflow; };
}, [lock]);
}
function preload(src?: string) {
if (!src) return;
const img = new Image();
img.decoding = 'async';
img.src = src;
}
export function Lightbox({
images,
index,
onClose,
onPrev,
onNext,
}: {
images: ImageItem[];
index: number;
onClose: () => void;
onPrev: () => void;
onNext: () => void;
}) {
const open = index >= 0 && index < images.length;
useScrollLock(open);
const closeBtnRef = useRef<HTMLButtonElement>(null);
const nextBtnRef = useRef<HTMLButtonElement>(null);
const prevBtnRef = useRef<HTMLButtonElement>(null);
// Keyboard shortcuts + focus trap
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onClose(); }
else if (e.key === 'ArrowRight') { e.preventDefault(); onNext(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); onPrev(); }
else if (e.key === 'Home') { e.preventDefault(); /* jump first */ }
else if (e.key === 'End') { e.preventDefault(); /* jump last */ }
else if (e.key === 'Tab') {
// naive focus trap across three controls and the image
const focusables = [prevBtnRef.current, closeBtnRef.current, nextBtnRef.current].filter(Boolean) as HTMLElement[];
const i = focusables.indexOf(document.activeElement as HTMLElement);
if (e.shiftKey) {
const t = i <= 0 ? focusables.length - 1 : i - 1; focusables[t].focus(); e.preventDefault();
} else {
const t = i === focusables.length - 1 ? 0 : i + 1; focusables[t].focus(); e.preventDefault();
}
}
};
document.addEventListener('keydown', onKey);
// Move initial focus to close button
closeBtnRef.current?.focus();
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose, onNext, onPrev]);
// Preload neighbors
useEffect(() => {
if (!open) return;
const prev = (index - 1 + images.length) % images.length;
const next = (index + 1) % images.length;
preload(images[prev]?.src);
preload(images[next]?.src);
}, [open, index, images]);
if (!open) return null;
const item = images[index];
const portalTarget = typeof document !== 'undefined' ? document.body : null;
if (!portalTarget) return null; // SSR safety
return ReactDOM.createPortal(
<div role="dialog" aria-modal="true" aria-label="Image viewer" className="lbRoot" onClick={onClose}>
<div className="lbStage" onClick={(e) => e.stopPropagation()}>
<img
className="lbImg"
src={item.src}
srcSet={item.srcSet}
sizes={item.sizes}
alt={item.alt}
decoding="async"
/>
<div className="lbChrome" aria-hidden="false">
<button ref={prevBtnRef} className="lbBtn prev" aria-label="Previous image" onClick={onPrev}>‹</button>
<button ref={closeBtnRef} className="lbBtn close" aria-label="Close" onClick={onClose}>×</button>
<button ref={nextBtnRef} className="lbBtn next" aria-label="Next image" onClick={onNext}>›</button>
<div className="lbCount" aria-live="polite">{index + 1} / {images.length}</div>
</div>
</div>
</div>,
portalTarget
);
}
Styles for the overlay and controls:
.lbRoot { position: fixed; inset: 0; background: color-mix(in srgb, #000 80%, transparent); display: grid; place-items: center; z-index: 9999; }
.lbStage { position: relative; max-width: min(96vw, 1400px); max-height: 90vh; }
.lbImg { max-width: 100%; max-height: 90vh; object-fit: contain; border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
.lbChrome { position: absolute; inset: 0; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; }
.lbBtn { background: rgba(0,0,0,.5); color: #fff; border: 0; width: 44px; height: 44px; border-radius: 999px; cursor: pointer; backdrop-filter: blur(4px); }
.lbBtn:hover { background: rgba(0,0,0,.7); }
.lbBtn.prev { position: absolute; left: -56px; top: 50%; transform: translateY(-50%); }
.lbBtn.next { position: absolute; right: -56px; top: 50%; transform: translateY(-50%); }
.lbBtn.close { position: absolute; top: -56px; right: 0; }
.lbCount { position: absolute; left: 0; bottom: -40px; color: #fff; font: 500 14px/1 system-ui, sans-serif; }
@media (max-width: 640px) {
.lbBtn.prev { left: 8px; }
.lbBtn.next { right: 8px; }
.lbBtn.close { top: 8px; right: 8px; }
}
Touch gestures (simple swipe)
For a lightweight swipe, use touchstart/touchend and a threshold. This is optional—many users will tap arrows.
// inside Lightbox component, add:
const startX = useRef<number | null>(null);
function onTouchStart(e: React.TouchEvent) { startX.current = e.touches[0].clientX; }
function onTouchEnd(e: React.TouchEvent) {
const sx = startX.current; if (sx == null) return; startX.current = null;
const dx = e.changedTouches[0].clientX - sx;
if (Math.abs(dx) > 40) { dx < 0 ? onNext() : onPrev(); }
}
// then apply to the stage wrapper:
<div className="lbStage" onClick={(e) => e.stopPropagation()} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
Composing it all
A page-level component brings the grid and lightbox together.
import React from 'react';
import { useLightbox } from './useLightbox';
import { GalleryGrid } from './GalleryGrid';
import { Lightbox } from './Lightbox';
import type { ImageItem } from './types';
export function Gallery({ images }: { images: ImageItem[] }) {
const lb = useLightbox(images.length);
return (
<>
<GalleryGrid images={images} onOpen={lb.open} />
{lb.isOpen && (
<Lightbox
images={images}
index={lb.index}
onClose={lb.close}
onPrev={lb.prev}
onNext={lb.next}
/>
)}
</>
);
}
Performance checklist
- Thumbnails
- Use dedicated, smaller JPEG/WEBP/AVIF thumbnails. Add loading=“lazy” and decoding=“async”.
- Consider layout stabilization with width/height or CSS aspect-ratio to prevent reflow.
- Large images
- Provide srcset/sizes so the lightbox loads the optimal candidate for the viewport.
- Preload adjacent slides with a lightweight Image() to make navigation feel instant.
- Caching/CDN
- Serve static images via a CDN with long max-age and immutable hashes.
- Prefer modern formats (AVIF/WEBP) with fallback if you must support older browsers.
- Many images
- Virtualize long grids with react-window or react-virtualized.
- Paginate or infinite-scroll with IntersectionObserver.
Accessibility checklist
- Each image needs meaningful alt text that communicates the content (not merely “image”).
- The lightbox is role=“dialog” with aria-modal=“true”.
- Focus is moved into the dialog when it opens and trapped while open; return focus to the launcher on close if desired.
- Controls have aria-labels; the current index is announced via an aria-live polite region.
- Avoid relying only on color; ensure contrast for controls and text.
- Ensure that clicking the backdrop closes the dialog, but not when interacting with content.
Optional: animation and polish
Add motion with CSS or a library such as Framer Motion. Keep animations subtle and interruptible.
/* quick fade/scale on open */
.lbRoot { animation: fadeIn .18s ease-out; }
.lbStage { animation: pop .18s ease-out; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pop { from { transform: translateY(4px) scale(.99) } to { transform: none } }
With Framer Motion, swap the wrappers with motion.div and use AnimatePresence for enter/exit.
Framework tips (Next.js, Remix, Vite)
- SSR safety: guard document access. Our Lightbox checks for document before creating a portal.
- Next.js Image: you can render thumbnails with next/image for optimized output and keep the lightbox’s main image as a regular
to simplify portal rendering. Or use next/image everywhere but set unoptimized for remote domains as needed.
- Code-splitting: dynamically import the Lightbox to avoid shipping it on first paint of the gallery page.
// Next.js example
import dynamic from 'next/dynamic';
const Lightbox = dynamic(() => import('./Lightbox').then(m => m.Lightbox), { ssr: false });
Testing the UX
Use React Testing Library and user-event to verify core flows.
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { Gallery } from './Gallery';
it('opens, navigates, and closes', async () => {
render(<Gallery images={[ /* ...mock images... */ ]} />);
await user.click(screen.getAllByRole('button', { name: /open image/i })[0]);
expect(screen.getByRole('dialog', { name: /image viewer/i })).toBeInTheDocument();
await user.keyboard('{ArrowRight}');
expect(screen.getByText('2 /')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
Hardening and edge cases
- Very tall/wide images: use object-fit: contain and cap max-height to viewport height.
- Focus restoration: store the last focused element before opening and restore it on close.
- Content Security Policy: if you inline images from third-party domains, update img-src in your CSP.
- RTL support: swap arrow labels/directions when document.dir === ‘rtl’.
- Reduced motion: respect prefers-reduced-motion with a media query to disable nonessential animation.
@media (prefers-reduced-motion: reduce) {
.lbRoot, .lbStage { animation: none !important; }
}
Putting it all together
This pattern—separating state, grid, and lightbox—keeps your code clean and testable. You get a responsive gallery that:
- opens instantly,
- preloads the next/previous images,
- supports keyboard and screen readers,
- locks background scroll and focus correctly,
- and scales with virtualization or pagination when your dataset grows.
From here, theme it with your design tokens, add captions, and wire analytics (e.g., slide viewed, time in lightbox) to better understand engagement. You now have a production-ready React image gallery and lightbox that prioritizes speed and accessibility without sacrificing polish.
Related Posts
Build a React Emoji Picker Component: A Complete Tutorial
Build a production-grade React emoji picker: search, categories, skin tones, keyboard support, theming, and optional virtualization.
Build a Rock‑Solid React Countdown Timer (Hooks, TypeScript, Zero Drift)
Build a robust React countdown timer with hooks and TypeScript: zero-drift updates, pause/resume/reset, accessibility, SSR tips, tests, and examples.
Building a Robust Password Strength Indicator in React
Learn how to build an accessible, accurate React password strength indicator with scoring logic, UX patterns, TypeScript code, and testing tips.