Let's build a fast, responsive e-commerce product grid component that handles image loading states, rapid filtering, and optimistic cart updates without layout shifts.
What you'll learn:
- Structuring a fluid, responsive product grid using Tailwind CSS.
- Implementing accessible image loaders to eliminate Cumulative Layout Shift (
CLS). - Managing local cart state cleanly with React hooks.
- Handling empty and loading states gracefully.
Setting Up the Product Card Component
Every great online store starts with a solid product card. We need to handle image aspect ratios, badges, and primary call-to-actions without cluttering the markup.
import React, { useState } from 'react';
interface Product {
id: string;
name: string;
price: number;
imageUrl: string;
category: string;
}
export function ProductCard({ product }: { product: Product }) {
const [isLoaded, setIsLoaded] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const handleAddToCart = () => {
setIsAdding(true);
setTimeout(() => setIsAdding(false), 600); // Simulate network latency
};
return (
<div className="group relative flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm transition-all hover:shadow-md">
<div className="aspect-square w-full overflow-hidden bg-neutral-100 relative">
{!isLoaded && (
<div className="absolute inset-0 animate-pulse bg-neutral-200" />
)}
<img
src={product.imageUrl}
alt={product.name}
onLoad={() => setIsLoaded(true)}
className={`h-full w-full object-cover object-center transition-opacity duration-300 group-hover:scale-105 ${
isLoaded ? 'opacity-100' : 'opacity-0'
}`}
/>
<span className="absolute top-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
{product.category}
</span>
</div>
<div className="flex flex-1 flex-col justify-between p-4">
<div>
<h3 className="text-sm font-semibold text-neutral-900">{product.name}</h3>
<p className="mt-1 text-sm font-bold text-neutral-900">${product.price.toFixed(2)}</p>
</div>
<button
onClick={handleAddToCart}
disabled={isAdding}
className="mt-4 w-full rounded-lg bg-neutral-900 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-neutral-800 disabled:bg-neutral-600"
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
</div>
</div>
);
}
Speed Note: Always enforce a strict aspect ratio (
aspect-squareoraspect-[4/5]) on product images. This prevents layout reflows while images are fetching over slow connections.
Structuring the Responsive Grid Layout
A production-ready store needs a grid that adapts gracefully from single-column mobile viewports to expansive multi-column desktop layouts.
import React from 'react';
import { ProductCard } from './ProductCard';
const SAMPLE_PRODUCTS = [
{ id: '1', name: 'Minimalist Desk Lamp', price: 89.00, imageUrl: 'https://images.unsplash.com/photo-1507473885765-e6ed057f782c', category: 'Lighting' },
{ id: '2', name: 'Ceramic Coffee Dripper', price: 34.50, imageUrl: 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd', category: 'Kitchen' },
{ id: '3', name: 'Ergonomic Wooden Tray', price: 45.00, imageUrl: 'https://images.unsplash.com/photo-1584308666744-24d5c474f2ae', category: 'Home' },
{ id: '4', name: 'Raw Brass Pen', price: 28.00, imageUrl: 'https://images.unsplash.com/photo-1583485088034-697b5bc54ccd', category: 'Stationery' },
];
export function ProductGrid() {
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-bold tracking-tight text-neutral-900">Featured Releases</h2>
<span className="text-xs text-neutral-500">{SAMPLE_PRODUCTS.length} products</span>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{SAMPLE_PRODUCTS.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
Accessibility Tip: Ensure action buttons inside repeating grid items include distinct aria-labels or remain clearly contextualized by surrounding card headings for screen reader users.
Handling Empty and Loading States
Never leave users staring at a blank screen or a broken layout when filters return no results or data is still fetching.
import React from 'react';
export function ProductGridState({ isLoading, isEmpty }: { isLoading: boolean; isEmpty: boolean }) {
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4 p-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-80 rounded-xl bg-neutral-100 animate-pulse" />
))}
</div>
);
}
if (isEmpty) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center text-neutral-400 mb-4">
📦
</div>
<h3 className="text-sm font-semibold text-neutral-900">No products found</h3>
<p className="mt-1 text-xs text-neutral-500">Try adjusting your category or price filters.</p>
</div>
);
}
return null;
}
Summary & Next Steps
Building a high-converting e-commerce interface relies heavily on perceived performance. By combining explicit image aspect ratios, skeleton loaders, and predictable grid layouts, you ensure a buttery-smooth shopping experience.
Next Steps:
- Integrate URL search params to make your category filters shareable.
- Implement server-side pagination or infinite scroll for inventories exceeding 50+ items.
- Add micro-animations for cart badge counters using Framer Motion or Tailwind transitions.