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.
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-avoidprevents 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.
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.
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.
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
srcsetattributes to yourtags 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.