Back to All Articles
reacttailwindcssperformanceui

Building a High-Performance Photo Gallery Grid with React and CSS Columns

Learn how to build a high-performance responsive photo gallery layout in React using CSS columns and optimized image loading patterns.

2026-10-15
Building a High-Performance Photo Gallery Grid with React and CSS Columns

Let's build a buttery-smooth, responsive photography portfolio grid in React that handles mixed aspect ratio images without layout shift or heavy JavaScript layout libraries.

What you'll learn:

  • Structuring a masonry-style grid using pure CSS multi-column properties.
  • Implementing native lazy loading and blur-up placeholders for large media assets.
  • Managing keyboard navigation and accessibility for an immersive lightbox view.
  • Optimizing paint performance and cumulative layout shifts (CLS) on mobile devices.

Setting Up the Grid Container

Standard CSS Grid struggles when items have wildly different heights (like portrait and landscape photographs). Instead of complex JavaScript calculation libraries, CSS multi-column layout gives us a natural flow that respects aspect ratios out of the box.

TSX
import React from 'react';

interface Photo {
  id: string;
  url: string;
  title: string;
  aspectRatio: number;
}

export const PhotoGrid: React.FC<{ photos: Photo[] }> = ({ photos }) => {
  return (
    <div className="columns-1 sm:columns-2 lg:columns-3 gap-6 [column-fill:_balance] box-border mx-auto max-w-7xl p-4">
      {photos.map((photo) => (
        <div key={photo.id} className="mb-6 break-inside-avoid">
          <img
            src={photo.url}
            alt={photo.title}
            loading="lazy"
            className="w-full rounded-lg object-cover shadow-md transition-transform duration-300 hover:scale-[1.01]"
          />
        </div>
      ))}
    </div>
  );
};

Speed note: Using break-inside-avoid prevents browser columns from splitting individual image cards down the middle, keeping your layouts clean and readable.

Handling Image Loading States and CLS

Unloaded images cause layout jumps when they finally render. To fix this, wrap your images in a fixed aspect-ratio container and use an inline background blur or solid skeleton color while the asset fetches.

TSX
import React, { useState } from 'react';

export const OptimizedPhotoCard: React.FC<{ url: string; title: string }> = ({ url, title }) => {
  const [isLoaded, setIsLoaded] = useState(false);

  return (
    <div className="mb-6 break-inside-avoid overflow-hidden rounded-xl bg-neutral-900 relative">
      <div 
        className={`absolute inset-0 bg-neutral-800 animate-pulse transition-opacity duration-500 ${
          isLoaded ? 'opacity-0 pointer-events-none' : 'opacity-100'
        }`} 
      />
      <img
        src={url}
        alt={title}
        loading="lazy"
        onLoad={() => setIsLoaded(true)}
        className={`w-full h-auto object-cover transition-opacity duration-500 ${
          isLoaded ? 'opacity-100' : 'opacity-0'
        }`}
      />
    </div>
  );
};

Gotcha: Never set explicit pixel heights on responsive portfolio images unless you lock down your aspect ratios. Let width scale naturally while height adjusts proportionally.

Adding Keyboard Navigation and Lightbox State

Photography sites live and die by their detail view. Users expect to click a thumbnail to open a fullscreen lightbox, cycle with arrow keys, and close with Escape.

TSX
import React, { useEffect } from 'react';

interface LightboxProps {
  photo: { url: string; title: string } | null;
  onClose: () => void;
  onNext: () => void;
  onPrev: () => void;
}

export const Lightbox: React.FC<LightboxProps> = ({ photo, onClose, onNext, onPrev }) => {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (!photo) return;
      if (e.key === 'Escape') onClose();
      if (e.key === 'ArrowRight') onNext();
      if (e.key === 'ArrowLeft') onPrev();
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [photo, onClose, onNext, onPrev]);

  if (!photo) return null;

  return (
    <div 
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4"
      onClick={onClose}
    >
      <div className="relative max-w-5xl max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
        <img src={photo.url} alt={photo.title} className="max-h-[85vh] w-auto rounded-lg object-contain shadow-2xl" />
        <p className="mt-3 text-center text-sm font-medium text-neutral-300">{photo.title}</p>
      </div>
    </div>
  );
};

Accessibility: Always trap focus inside the modal or ensure your close button has clear aria-labels and receives initial focus when the lightbox opens.

Optimizing Performance for High-Res Assets

High-end photography sites deal with heavy multi-megabyte files. Delivering uncompressed RAWs to mobile devices tanks your Core Web Vitals. Always leverage modern CDNs with dynamic query parameters for resizing and format negotiation.

TSX
export const getOptimizedImageUrl = (baseUrl: string, width: number, quality: number = 80) => {
  // Example helper assuming an image CDN like Cloudinary or Unsplash source API
  const separator = baseUrl.includes('?') ? '&' : '?';
  return `${baseUrl}${separator}w=${width}&q=${quality}&auto=format`;
};

Tip: Pass dynamic srcset attributes to your tags so desktop viewports fetch 1920px widths while mobile devices pull scaled 768px variants.

Summary & Next Steps

Building a professional photography experience requires balancing raw visual impact with strict web performance standards. By leveraging CSS columns, native lazy loading, and lightweight state management, you get buttery-smooth layouts without bloated dependencies.

Next Steps:

  • Implement URL hash routing so users can deep-link directly to individual lightbox photos.
  • Add touch swipe detection for mobile devices to cycle through gallery items seamlessly.