EmpireUI
Get Pro
← Blog7 min read#image-gallery#lightbox#react

Image Gallery with Lightbox: Accessible Photo Viewer in React

Build a fully accessible image gallery with lightbox in React. Keyboard nav, focus trapping, lazy loading, and Tailwind styling — no third-party lightbox library needed.

A grid of landscape photographs displayed in a clean photo gallery layout

Why Most React Lightboxes Are Overcomplicated

Honestly, most lightbox libraries you'll find on npm are 300 KB bundles that do 90% of things you don't need. You install react-image-lightbox, wire up 14 props, patch a couple of accessibility issues yourself anyway, and end up with a dependency you're scared to upgrade. There's a better way.

This guide walks you through building an image gallery component with a lightbox overlay from scratch — just React hooks, Tailwind CSS (v4.0.2 used here), and a little browser API knowledge. No extra dependencies. You'll end up with something you actually understand and can maintain.

We'll cover the grid layout, the overlay, keyboard navigation, focus trapping, and lazy loading. Each piece is small. Together they're a complete, production-ready component.

Gallery Grid Layout with Tailwind CSS

The grid itself is the easy part. A columns-3 masonry approach works for photography portfolios where images have varying heights. For uniform thumbnails — product shots, avatars, screenshots — a standard grid-cols-3 with aspect-square is cleaner and more predictable.

Here's a solid starting point. We're using an 8px gap (gap-2) between cells, overflow-hidden on each item, and object-cover so nothing gets squashed. The cursor-pointer and focus-visible ring on the button element handles both mouse and keyboard users from the start.

type GalleryImage = {
  id: string;
  src: string;
  alt: string;
  width: number;
  height: number;
};

type GalleryGridProps = {
  images: GalleryImage[];
  onOpen: (index: number) => void;
};

export function GalleryGrid({ images, onOpen }: GalleryGridProps) {
  return (
    <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
      {images.map((img, index) => (
        <button
          key={img.id}
          onClick={() => onOpen(index)}
          className="relative aspect-square overflow-hidden rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2"
          aria-label={`View ${img.alt}`}
        >
          <img
            src={img.src}
            alt={img.alt}
            loading="lazy"
            className="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
          />
        </button>
      ))}
    </div>
  );
}

Building the Lightbox Overlay Component

The lightbox is a dialog-like overlay that renders above everything else. Using a real <dialog> HTML element is tempting, but browser support for the showModal() method is still inconsistent enough that a div with role="dialog" and aria-modal="true" is more reliable as of mid-2026. Set aria-labelledby to point at the image caption and you've got a complete ARIA pattern.

The backdrop uses rgba(0,0,0,0.92) rather than a full black — it keeps the illusion of depth. The image container is max-h-[90vh] max-w-[90vw] so it never overflows on small screens. Add object-contain and you're done with the image sizing headaches.

type LightboxProps = {
  images: GalleryImage[];
  currentIndex: number;
  onClose: () => void;
  onPrev: () => void;
  onNext: () => void;
};

export function Lightbox({
  images,
  currentIndex,
  onClose,
  onPrev,
  onNext,
}: LightboxProps) {
  const image = images[currentIndex];

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="lightbox-caption"
      className="fixed inset-0 z-50 flex items-center justify-center"
      style={{ background: 'rgba(0,0,0,0.92)' }}
      onClick={onClose}
    >
      <div
        className="relative flex items-center justify-center"
        onClick={(e) => e.stopPropagation()}
      >
        <img
          src={image.src}
          alt={image.alt}
          className="max-h-[90vh] max-w-[90vw] object-contain rounded-lg"
        />
        <p id="lightbox-caption" className="sr-only">{image.alt}</p>
      </div>

      <button
        onClick={onPrev}
        aria-label="Previous image"
        className="absolute left-4 text-white text-4xl px-3 py-1 hover:bg-white/10 rounded-full transition"
      >
        &#8249;
      </button>
      <button
        onClick={onNext}
        aria-label="Next image"
        className="absolute right-4 text-white text-4xl px-3 py-1 hover:bg-white/10 rounded-full transition"
      >
        &#8250;
      </button>
      <button
        onClick={onClose}
        aria-label="Close lightbox"
        className="absolute top-4 right-4 text-white text-2xl hover:bg-white/10 rounded-full p-2 transition"
      >
        &#10005;
      </button>
    </div>
  );
}

Keyboard Navigation and Focus Trapping

This is the part most tutorials skip. An inaccessible lightbox is worse than no lightbox — it traps keyboard users on the page behind the overlay. You need two things: arrow key navigation between images, and focus trapped inside the overlay while it's open.

The arrow key handler is a useEffect on keydown. Left arrow calls onPrev, right arrow calls onNext, Escape calls onClose. Simple. What's slightly trickier is focus trapping. When the lightbox mounts, you shift focus inside it. When it unmounts, you return focus to the thumbnail that opened it. Use a ref on the trigger button and call .focus() in the cleanup.

import { useEffect, useRef } from 'react';

export function useLightboxKeyboard({
  onClose,
  onPrev,
  onNext,
  triggerRef,
}: {
  onClose: () => void;
  onPrev: () => void;
  onNext: () => void;
  triggerRef: React.RefObject<HTMLButtonElement>;
}) {
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    // Move focus into lightbox
    closeButtonRef.current?.focus();

    const handler = (e: KeyboardEvent) => {
      if (e.key === 'ArrowLeft') onPrev();
      if (e.key === 'ArrowRight') onNext();
      if (e.key === 'Escape') onClose();
    };

    window.addEventListener('keydown', handler);
    return () => {
      window.removeEventListener('keydown', handler);
      // Return focus to trigger on unmount
      triggerRef.current?.focus();
    };
  }, [onClose, onPrev, onNext, triggerRef]);

  return { closeButtonRef };
}

You can pair this component with Empire UI's animated button components for the prev/next controls if you want consistent styling across your project. The focus management pattern works regardless of what the buttons look like.

State Management: Opening, Closing, and Navigating

Wiring the gallery and lightbox together takes about 20 lines of state in the parent component. A single activeIndexnull when closed, a number when open. That's it. No reducers needed.

import { useState, useRef } from 'react';
import { GalleryGrid } from './GalleryGrid';
import { Lightbox } from './Lightbox';

const images: GalleryImage[] = [
  { id: '1', src: '/photos/1.jpg', alt: 'Mountain at sunrise', width: 1200, height: 800 },
  { id: '2', src: '/photos/2.jpg', alt: 'Ocean waves at dusk', width: 1200, height: 900 },
  // ...
];

export function PhotoGallery() {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const triggerRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const open = (index: number) => setActiveIndex(index);
  const close = () => setActiveIndex(null);
  const prev = () => setActiveIndex(i =>
    i === null ? null : (i - 1 + images.length) % images.length
  );
  const next = () => setActiveIndex(i =>
    i === null ? null : (i + 1) % images.length
  );

  return (
    <>
      <GalleryGrid images={images} onOpen={open} />
      {activeIndex !== null && (
        <Lightbox
          images={images}
          currentIndex={activeIndex}
          onClose={close}
          onPrev={prev}
          onNext={next}
        />
      )}
    </>
  );
}

The modular index avoids state in the lightbox itself. Have you noticed how many lightbox examples dump all their logic into one massive component? Splitting it this way means you can swap out the grid or the overlay independently.

Lazy Loading and Performance for Large Galleries

Adding loading="lazy" to <img> tags buys you a lot for free. Browsers won't fetch images below the fold until the user scrolls close. For galleries with 50+ images, that's the difference between a 200ms and a 2s initial load.

For really large collections — think 200+ images — you'll want virtualization. react-window or @tanstack/react-virtual both work well here. The lightbox itself should preload the adjacent images (index - 1 and index + 1) as hidden <img> elements when it opens. That way, clicking next feels instant.

Also think about image formats. If you're self-hosting, WebP with a JPEG fallback cuts file sizes by 30-50%. Next.js <Image> handles this automatically with its optimization pipeline. If you're using a CDN like Cloudinary or Imgix, pass width and quality parameters in the URL — ?w=400&q=70 for thumbnails, ?w=1400&q=85 for the lightbox view.

The thumbnail grid pairs nicely with a bento grid layout if you're building a portfolio or media-heavy landing page. You get variable-sized cells that give featured images more visual weight.

Styling the Lightbox with Tailwind v4 and Custom Animations

The enter/exit animation is where the lightbox goes from feeling clunky to feeling polished. A simple fade-in on the backdrop plus a subtle scale-up on the image — scale(0.96) to scale(1) over 200ms — is enough. Don't go overboard. Fast UI feels respectful of the user's time.

In Tailwind v4.0.2, you can define the animation in your CSS config and reference it as a utility class. Here's a minimal setup:

/* globals.css */
@keyframes lightbox-enter {
  from {
    opacity: 0;
    transform: scale(0.96);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes lightbox-backdrop {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@utility animate-lightbox-enter {
  animation: lightbox-enter 200ms ease-out both;
}

@utility animate-lightbox-backdrop {
  animation: lightbox-backdrop 150ms ease-out both;
}

Then apply animate-lightbox-backdrop to the overlay div and animate-lightbox-enter to the image container. Exit animations are harder without a library — you'd need to track an isClosing state, apply the reverse animation class, then call onClose after the animation duration. For most projects, skipping exit animations and just removing the element instantly is fine. The enter animation does the heavy lifting for perceived quality.

If you want more interactive UI patterns alongside your gallery, check out animated tabs for filtering galleries by category — the two components work really naturally together.

Putting It All Together: Full Component Checklist

Before you ship, run through this. role="dialog" and aria-modal="true" on the overlay — check. Focus moves into the overlay on open and returns to the trigger on close — check. Arrow keys and Escape work — check. Clicking the backdrop closes the lightbox — check. The close button has an aria-label — check.

On the performance side: thumbnails use loading="lazy" — check. The lightbox image uses the full-size source, not the thumbnail — check. Adjacent images are preloaded — check. The component doesn't re-render the whole gallery when the lightbox state changes (your state is in the parent, not a context that wraps the grid) — check.

What about mobile? Touch swipe support for the lightbox is the one thing not covered here. You'd add touchstart and touchend listeners, calculate the swipe delta, and call onPrev/onNext when it crosses a 50px threshold. It's another 30 lines and it makes a huge difference on phones. Worth adding if this gallery is public-facing.

For more React component patterns from the same library, card stack layouts use similar overlay and z-index stacking techniques. The mental model transfers directly.

FAQ

Do I need a library like yet-another-react-lightbox or react-image-lightbox?

Not for most use cases. A custom lightbox is around 150 lines of React and handles accessibility better because you control every detail. Libraries make sense when you need features like zooming, deep linking, or slideshow autoplay — and even then, evaluate the bundle size first.

How do I prevent the page from scrolling when the lightbox is open?

Add document.body.style.overflow = 'hidden' when the lightbox mounts and reset it to '' on unmount inside a useEffect cleanup. If you're using Next.js App Router, do this inside a useEffect — never in a Server Component.

What's the right ARIA pattern for a lightbox dialog?

Use role="dialog", aria-modal="true", and aria-labelledby pointing to a caption element. If there's no visible caption, add one with the sr-only class. Move focus inside the dialog on open and return it to the trigger element on close.

How do I handle images with different aspect ratios in the grid without layout shifts?

Use aspect-square with object-cover for uniform thumbnail grids. For a masonry layout where you want to preserve natural proportions, use CSS columns instead of grid, and set a minimum height of 200px on each cell. Either way, set explicit width and height attributes on your <img> tags to prevent cumulative layout shift (CLS).

Can this gallery component work with Next.js Image optimization?

Yes. Replace <img> with Next.js <Image> and pass fill prop with object-cover for thumbnails, then set explicit width and height for the lightbox image. You'll lose the loading="lazy" attribute (Next.js handles this automatically) but gain automatic WebP conversion and responsive srcset.

How do I add URL-based deep linking so the lightbox can be shared as a direct link?

Store the active index in a query param: ?photo=2. On mount, read searchParams.get('photo') and initialize state from it. Update the URL with router.push (shallow) when the user navigates. On close, remove the param. This gives you shareable lightbox URLs without a full page reload.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Floating Action Button in React: FAB with Speed DialCustom Select Dropdown in React: Searchable, Multi-SelectFrosted Glass Tooltip: Accessible Popover with Blur EffectGlass Navigation Bar: Sticky Header with Backdrop Blur