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.

ASOasis
9 min read
Building a Fast, Accessible React Image Gallery with a Lightbox

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 };
}

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