EmpireUI
Get Pro
← Blog8 min read#image gallery#react#masonry

Image Gallery in React: Masonry Grid, Lightbox and Lazy Load

Build a React image gallery with masonry layout, lightbox overlay, and lazy loading — no heavy libraries required. Code-first guide with hooks and CSS Grid.

React image gallery with masonry grid layout and lightbox overlay

What We're Actually Building

A lot of tutorials stop at slapping react-masonry-css in your package.json and calling it a day. That's fine if you need something in 10 minutes, but you end up with a black-box dependency, a layout you can't customize past its exposed props, and zero understanding of why your images jump around on resize. This guide doesn't do that.

You're going to build three things: a masonry grid using CSS columns (the underappreciated approach that actually works), a lightbox with keyboard navigation and a focus trap, and lazy loading via the IntersectionObserver API backed by loading='lazy' where supported. All of it in React 18+ with hooks. No class components, no legacy lifecycle methods.

Honestly, the component you end up with here is closer to production-ready than most NPM packages for this use case. It'll handle variable-height images, mobile viewports down to 320px, accessibility concerns, and SSR without blinking. Worth noting: if you want this wrapped in a complete design system with pre-styled UI, Empire UI has gallery components across all major visual styles — but understanding the mechanics first makes you a better consumer of those too.

We're targeting React 18.3+ throughout. Most of the patterns will work back to React 16.8 (hooks land) but the useId() hook we use for the lightbox aria attributes requires 18.

Masonry Grid with CSS Columns

CSS Grid's masonry value has been in Firefox behind a flag since 2020 and still hasn't shipped cross-browser in 2026. So we use the real, battle-tested approach: CSS `column-count` + `break-inside: avoid`. It's been universally supported since IE10 and needs zero JavaScript to reflow.

// MasonryGrid.tsx
import { ReactNode } from 'react';

interface MasonryGridProps {
  columns?: number;
  gap?: number;
  children: ReactNode;
}

export function MasonryGrid({
  columns = 3,
  gap = 16,
  children,
}: MasonryGridProps) {
  return (
    <div
      style={{
        columnCount: columns,
        columnGap: gap,
      }}
    >
      {children}
    </div>
  );
}

// MasonryItem.tsx
export function MasonryItem({ children }: { children: ReactNode }) {
  return (
    <div
      style={{
        breakInside: 'avoid',
        marginBottom: 16,
        display: 'block',
      }}
    >
      {children}
    </div>
  );
}

Responsive columns without a single media-query JavaScript listener? Use column-count alongside column-width. Setting column-width: 280px and column-count: auto tells the browser "fill these columns at 280px each, use as many as fit" — you get implicit responsiveness for free. On a 1440px viewport you get ~5 columns; on a 375px phone you get one. Clean.

One wrinkle: column-count lays out items top-to-bottom per column, not left-to-right per row. For most photo galleries this is actually what you want — the reading order feels natural. But if your CMS sends you images in a strict order that needs to be preserved horizontally (e.g., numbered steps), you'll want a JavaScript-based approach instead. That said, for aesthetics-first galleries this is ideal.

Quick aside: resist the urge to set img { width: 100%; height: auto } globally and call it done. You need display: block on the image too, or inline-block leaves a small gap at the bottom of each image that breaks the clean column alignment. Four pixels of phantom whitespace will drive you absolutely mad.

Lazy Loading: Intersection Observer + Native loading

The native loading='lazy' attribute on <img> is supported in all modern browsers since 2022 and should be your first line of defense. It's zero-JS, works with SSR, and the browser's built-in heuristics for when to start loading (typically ~200px before entering the viewport) are more accurate than anything you'd write yourself.

// LazyImage.tsx
import { useRef, useState, useEffect } from 'react';

interface LazyImageProps {
  src: string;
  alt: string;
  width: number;
  height: number;
  onClick?: () => void;
}

export function LazyImage({ src, alt, width, height, onClick }: LazyImageProps) {
  const [loaded, setLoaded] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  // Skeleton fade-out when image decodes
  useEffect(() => {
    if (imgRef.current?.complete) setLoaded(true);
  }, []);

  return (
    <div
      style={{
        position: 'relative',
        aspectRatio: `${width} / ${height}`,
        background: '#1a1a2e',
        borderRadius: 8,
        overflow: 'hidden',
        cursor: onClick ? 'pointer' : 'default',
      }}
      onClick={onClick}
    >
      {!loaded && (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            background: 'linear-gradient(90deg, #1a1a2e 25%, #2d2d4e 50%, #1a1a2e 75%)',
            backgroundSize: '200% 100%',
            animation: 'shimmer 1.5s infinite',
          }}
        />
      )}
      <img
        ref={imgRef}
        src={src}
        alt={alt}
        loading="lazy"
        decoding="async"
        width={width}
        height={height}
        onLoad={() => setLoaded(true)}
        style={{
          width: '100%',
          height: '100%',
          objectFit: 'cover',
          display: 'block',
          opacity: loaded ? 1 : 0,
          transition: 'opacity 0.3s ease',
        }}
      />
    </div>
  );
}

The aspectRatio trick on the wrapper container is load-bearing. By setting aspect-ratio: width / height you reserve the exact vertical space before the image loads, eliminating layout shift (CLS = 0). Google's Core Web Vitals reward this. If you don't know your image dimensions upfront — say you're pulling from an unsplash or user-upload endpoint — fall back to a fixed padding-top percentage trick or set a minimum height of 200px.

For cases where you need finer control than loading='lazy' gives you — custom thresholds, analytics on which images actually get viewed, or progressive JPEG loading — wire up IntersectionObserver manually. The hook below fires a callback when an element crosses the 10% threshold of the viewport:

// useInView.ts
import { useEffect, useRef, useState } from 'react';

export function useInView(threshold = 0.1) {
  const ref = useRef<HTMLElement>(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          observer.disconnect(); // fire once and stop watching
        }
      },
      { threshold }
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold]);

  return { ref, inView };
}

In practice, don't use both the native attribute and the IntersectionObserver hook for the same image unless you have a specific reason. Pick one. The native loading='lazy' is fine 95% of the time. Keep the hook for analytics instrumentation or when you want to swap a blurred placeholder for the full image in a way the browser can't handle natively.

Lightbox with Keyboard Nav and Focus Trap

The lightbox is where most tutorials get lazy and ship a position: fixed overlay with a click-to-close and call it accessible. It's not. You need keyboard navigation (arrow keys for prev/next, Escape to close), a focus trap so Tab doesn't escape the dialog, and proper ARIA roles so screen readers announce "Image 3 of 12" rather than dumping a raw <div> on the user.

// Lightbox.tsx
import { useEffect, useId, useCallback } from 'react';

interface LightboxProps {
  images: { src: string; alt: string }[];
  currentIndex: number;
  onClose: () => void;
  onPrev: () => void;
  onNext: () => void;
}

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

  const handleKey = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'ArrowLeft') onPrev();
      if (e.key === 'ArrowRight') onNext();
    },
    [onClose, onPrev, onNext]
  );

  useEffect(() => {
    document.addEventListener('keydown', handleKey);
    document.body.style.overflow = 'hidden';
    return () => {
      document.removeEventListener('keydown', handleKey);
      document.body.style.overflow = '';
    };
  }, [handleKey]);

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-label={`Image ${currentIndex + 1} of ${images.length}: ${alt}`}
      id={dialogId}
      style={{
        position: 'fixed',
        inset: 0,
        background: 'rgba(0,0,0,0.92)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: 9999,
      }}
      onClick={onClose}
    >
      <img
        src={src}
        alt={alt}
        onClick={(e) => e.stopPropagation()}
        style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 4 }}
      />
      <button
        onClick={(e) => { e.stopPropagation(); onPrev(); }}
        aria-label="Previous image"
        style={{ position: 'absolute', left: 24, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: '50%', width: 48, height: 48, cursor: 'pointer', color: '#fff', fontSize: 20 }}
      >‹</button>
      <button
        onClick={(e) => { e.stopPropagation(); onNext(); }}
        aria-label="Next image"
        style={{ position: 'absolute', right: 24, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: '50%', width: 48, height: 48, cursor: 'pointer', color: '#fff', fontSize: 20 }}
      >›</button>
      <button
        onClick={onClose}
        aria-label="Close lightbox"
        style={{ position: 'absolute', top: 24, right: 24, background: 'none', border: 'none', color: '#fff', fontSize: 28, cursor: 'pointer' }}
      >×</button>
      <p style={{ position: 'absolute', bottom: 24, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
        {currentIndex + 1} / {images.length}
      </p>
    </div>
  );
}

Look, the document.body.style.overflow = 'hidden' line prevents the page from scrolling behind the lightbox. It's one of those things that seems obvious but you'll spend 20 minutes debugging it the first time you forget. The cleanup in useEffect's return resets it when the lightbox unmounts.

For the focus trap, the implementation above doesn't have a full one — which is a deliberate trade-off. Adding a complete focus trap (tabbing cycles within the dialog only) adds ~30 lines of DOM querying. If you ship this to a production app with an a11y requirement, pull in the focus-trap-react package. It wraps any element and manages focus capture with one prop. For internal tools or portfolios, the keyboard navigation above covers 90% of real-world usage.

One more thing — the useId() hook from React 18 generates a stable, server-safe ID for the id attribute. Don't use Math.random() for IDs or you'll get hydration mismatches in Next.js. useId() was introduced in React 18.0.0 specifically because this pattern was so commonly botched.

Wiring Everything Together

Here's the top-level gallery component that ties masonry, lazy loading, and lightbox together with a single useState for which image is open:

// ImageGallery.tsx
import { useState } from 'react';
import { MasonryGrid, MasonryItem } from './MasonryGrid';
import { LazyImage } from './LazyImage';
import { Lightbox } from './Lightbox';

const IMAGES = [
  { src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800', alt: 'Mountain lake at dawn', width: 800, height: 600 },
  { src: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800', alt: 'Forest path in autumn', width: 800, height: 1067 },
  { src: 'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=800', alt: 'Desert sand dunes', width: 800, height: 534 },
  // add more...
];

export function ImageGallery() {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  const handlePrev = () =>
    setActiveIndex((i) => (i !== null ? Math.max(0, i - 1) : null));

  const handleNext = () =>
    setActiveIndex((i) => (i !== null ? Math.min(IMAGES.length - 1, i + 1) : null));

  return (
    <>
      <MasonryGrid columns={3} gap={16}>
        {IMAGES.map((img, idx) => (
          <MasonryItem key={img.src}>
            <LazyImage
              src={img.src}
              alt={img.alt}
              width={img.width}
              height={img.height}
              onClick={() => setActiveIndex(idx)}
            />
          </MasonryItem>
        ))}
      </MasonryGrid>

      {activeIndex !== null && (
        <Lightbox
          images={IMAGES}
          currentIndex={activeIndex}
          onClose={() => setActiveIndex(null)}
          onPrev={handlePrev}
          onNext={handleNext}
        />
      )}
    </>
  );
}

The activeIndex being null (not -1, not false) is intentional. Null means no lightbox, a number means lightbox open at that index. This makes the conditional render (activeIndex !== null) unambiguous and avoids edge cases where activeIndex === 0 would be falsy.

Want to pull images from an API instead of a static array? Replace the IMAGES constant with a useSWR or useQuery call. The gallery component doesn't care where the data comes from — just feed it an array with src, alt, width, and height. If your API doesn't return dimensions (common with user uploads), add a onLoad handler on the image that reads naturalWidth/naturalHeight and stores them in state before showing the image. Slightly more complex, but doable.

For the visual polish layer — hover overlays, animated scale-on-hover, gradient color themes — this is where a component library earns its keep. Empire UI's glassmorphism components work as image card overlays out of the box, giving you that frosted-glass caption effect on hover without custom CSS. You can also try the gradient generator to produce the color scheme for your gallery container background.

Performance Tips and Production Gotchas

The biggest performance mistake in React image galleries is skipping the width and height attributes on <img>. Without them, the browser can't reserve layout space before the image loads, which means every image that lazily loads in causes a reflow and tanks your CLS score. Always pass explicit dimensions. If you're using Next.js, the <Image> component from next/image handles this automatically and adds automatic WebP conversion — use it.

For very large galleries (500+ images), the masonry approach starts hurting because all those DOM nodes exist at once. Switch to windowing: react-virtual or @tanstack/react-virtual renders only the items in viewport + a small buffer. The API fits naturally with your existing LazyImage component — just feed the virtualizer your items array and render the slice it gives back.

Worth noting: if you're serving your own images, run them through a CDN that supports on-the-fly resizing. Cloudinary, Imgix, and Cloudflare Images all accept URL parameters like ?w=400&f=webp&q=75. Serve 400px thumbnails in the gallery and full-resolution only when the lightbox opens. That single change can cut your gallery's initial payload from 8MB to under 800KB.

One more thing — test on real mobile hardware, not just Chrome DevTools device mode. The column layout can go sideways on older iOS Safari versions if you haven't set -webkit-column-count alongside column-count. And throttle your network to Slow 3G in DevTools to make sure your skeleton shimmer actually appears before the images load. It's surprising how often you miss that locally because your Wi-Fi is too fast to see the loading state.

If you want to go further with interaction patterns — swipe gestures on mobile for the lightbox, pinch-to-zoom, or animated transitions between gallery items — Framer Motion integrates cleanly with this component structure. Wrap the lightbox image in <motion.img> and add layoutId matching the thumbnail to get shared-element transitions with about 10 extra lines.

FAQ

Do I need a library like react-masonry-css or react-photo-gallery?

You don't. CSS column-count handles variable-height masonry without JavaScript, and it's been supported everywhere since IE10. Libraries add abstraction without adding capability for the common case.

What's the difference between loading='lazy' and IntersectionObserver for images?

Native loading='lazy' is zero-JS and lets the browser decide the loading threshold — usually 200–400px before viewport. IntersectionObserver gives you a custom threshold and a callback you can hook into for analytics or progressive loading. Use native lazy first, reach for the Observer only when you need the control.

How do I prevent layout shift (CLS) in a React image gallery?

Pass explicit width and height attributes to every <img> and set aspect-ratio on the wrapper element. This reserves space before the image loads so nothing shifts when it arrives.

Is the lightbox component accessible to screen reader users?

The implementation above uses role='dialog', aria-modal='true', and an aria-label that includes the image position and alt text. Add focus-trap-react if your app has a formal a11y requirement — the manual keyboard handler alone isn't enough for WCAG 2.1 AA.

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

Read next

Masonry Grid in React: CSS columns vs JavaScript Grid LayoutImage Gallery with Lightbox: Accessible Photo Viewer in ReactIntersection Observer Advanced Patterns: Lazy Load, Sentinel, AnalyticsReact Performance in 2026: The 9 Optimizations That Actually Work