Product Grid with Responsive Layouts
Build a responsive product grid that adapts to different screen sizes. Add grid density controls and handle empty states gracefully.
A well-designed product grid adapts to screen sizes, offers user control over density, and handles edge cases like empty results. Let’s build a flexible grid system.
Theme Integration
This component is part of the collection page component hierarchy:
sections/collection-products.liquid└── <div id="collection-products-root"> └── CollectionPage (React) └── ProductGrid ← You are here └── ProductCard (multiple)See Collection Page Architecture for the complete Liquid section setup and data serialization.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
products | Parent (CollectionPage) / Zustand store | collection.products (array) |
gridColumns | Zustand store | - (user preference, localStorage) |
activeFilters | Zustand store | - (derived from URL params) |
isLoading | Zustand store | - |
hasNextPage | Zustand store | Derived from paginate.pages |
Grid Layout Strategy
Desktop (4 columns) Tablet (3 columns) Mobile (2 columns)┌────┬────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┐│ │ │ │ │ │ │ │ │ │ │ │├────┼────┼────┼────┤ ├────┼────┼────┤ ├────┼────┤│ │ │ │ │ │ │ │ │ │ │ │├────┼────┼────┼────┤ ├────┼────┼────┤ ├────┼────┤│ │ │ │ │ │ │ │ │ │ │ │└────┴────┴────┴────┘ └────┴────┴────┘ └────┴────┘The Product Grid Component
import type { CollectionProduct } from '@/types/collection';import { useCollection } from '@/stores/collection';import { ProductCard } from '../ProductCard';import { GridControls } from './GridControls';import { EmptyState } from './EmptyState';import styles from './ProductGrid.module.css';
interface ProductGridProps { products: CollectionProduct[]; // Array of products to display in the grid.}
/** * ProductGrid renders a responsive grid of product cards. * Supports configurable column count and handles empty states. */export function ProductGrid({ products }: ProductGridProps) { // Get user's preferred column count from the store. const gridColumns = useCollection((state) => state.gridColumns);
// Handle empty results with a dedicated component. if (products.length === 0) { return <EmptyState />; }
return ( <div className={styles.container}> {/* Header with product count and grid density controls */} <div className={styles.header}> <p className={styles.count}> {products.length} {products.length === 1 ? 'product' : 'products'} </p> <GridControls /> </div>
{/* Grid container - data-columns drives CSS variable */} <div className={styles.grid} data-columns={gridColumns}> {products.map((product, index) => ( <ProductCard key={product.id} product={product} // Prioritize loading for first 4 images (above-fold). // This improves Largest Contentful Paint (LCP). priority={index < 4} /> ))} </div> </div> );}Grid Styles with CSS Custom Properties
.container { width: 100%;}
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;}
.count { margin: 0; font-size: 0.875rem; color: var(--color-text-muted);}
.grid { display: grid; gap: var(--grid-gap, 1.5rem); grid-template-columns: repeat(var(--grid-columns, 4), 1fr);}
/* Column overrides based on data attribute */.grid[data-columns="2"] { --grid-columns: 2;}
.grid[data-columns="3"] { --grid-columns: 3;}
.grid[data-columns="4"] { --grid-columns: 4;}
/* Responsive breakpoints */@media (max-width: 1279px) { .grid[data-columns="4"] { --grid-columns: 3; }}
@media (max-width: 1023px) { .grid { --grid-columns: 3; }
.grid[data-columns="2"] { --grid-columns: 2; }}
@media (max-width: 767px) { .grid { --grid-columns: 2; --grid-gap: 1rem; }}
@media (max-width: 479px) { .grid { --grid-gap: 0.75rem; }}Grid Controls Component
Let users choose grid density:
import { useCollection } from '@/stores/collection';import styles from './GridControls.module.css';
// Define available grid column options.const GRID_OPTIONS = [ { columns: 2 as const, icon: 'grid-2' }, { columns: 3 as const, icon: 'grid-3' }, { columns: 4 as const, icon: 'grid-4' },];
/** * GridControls provides buttons to change the product grid column count. * User preference is persisted via the store (which saves to localStorage). */export function GridControls() { const gridColumns = useCollection((state) => state.gridColumns); const setGridColumns = useCollection((state) => state.setGridColumns);
return ( // role="group" groups related buttons for screen readers. <div className={styles.controls} role="group" aria-label="Grid density"> {GRID_OPTIONS.map((option) => ( <button key={option.columns} type="button" className={`${styles.button} ${gridColumns === option.columns ? styles.active : ''}`} onClick={() => setGridColumns(option.columns)} aria-label={`${option.columns} columns`} // Accessibility: describe action. aria-pressed={gridColumns === option.columns} // Accessibility: toggle state. > <GridIcon columns={option.columns} /> </button> ))} </div> );}
/** * GridIcon dynamically generates an SVG icon representing the grid layout. * Calculates box positions based on column count to visually indicate density. */function GridIcon({ columns }: { columns: 2 | 3 | 4 }) { const gaps = columns - 1; // Number of gaps between columns. const boxWidth = (20 - gaps * 2) / columns; // Calculate box width to fit in 20px.
return ( <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"> {/* Generate 2 rows of boxes based on column count */} {Array.from({ length: columns * 2 }).map((_, index) => { const column = index % columns; const row = Math.floor(index / columns); const x = column * (boxWidth + 2); // Position with gap. const y = row * 11; // Row offset.
return ( <rect key={index} x={x} y={y} width={boxWidth} height={8} rx={1} // Rounded corners. /> ); })} </svg> );}.controls { display: none; gap: 0.25rem;}
@media (min-width: 768px) { .controls { display: flex; }}
.button { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-muted); cursor: pointer; transition: all 0.15s ease;}
.button:first-child { border-radius: 4px 0 0 4px;}
.button:last-child { border-radius: 0 4px 4px 0;}
.button:not(:first-child) { margin-left: -1px;}
.button:hover { color: var(--color-text); z-index: 1;}
.button.active { background: var(--color-text); border-color: var(--color-text); color: var(--color-background); z-index: 2;}Empty State Component
Handle the case when no products match filters:
import { useCollection } from '@/stores/collection';import { Button } from '@/components/ui';import styles from './EmptyState.module.css';
/** * EmptyState displays when no products match the current filters. * Provides contextual messaging and a CTA to clear filters if applicable. */export function EmptyState() { const activeFilters = useCollection((state) => state.activeFilters); const clearAllFilters = useCollection((state) => state.clearAllFilters);
// Check if any filters are active to customize the message. const hasFilters = Object.keys(activeFilters).length > 0;
return ( <div className={styles.empty}> {/* Visual icon to reinforce the "no results" state */} <div className={styles.icon}> <SearchIcon /> </div>
<h3 className={styles.title}>No products found</h3>
{/* Contextual message: different copy based on filter state */} <p className={styles.message}> {hasFilters ? "Try adjusting your filters to find what you're looking for." : 'Check back soon for new products.'} </p>
{/* Only show "Clear All Filters" button if filters are active */} {hasFilters && ( <Button variant="secondary" onClick={clearAllFilters}> Clear All Filters </Button> )} </div> );}
/** * Simple search/magnifying glass icon for the empty state. */function SearchIcon() { return ( <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" > <circle cx="11" cy="11" r="8" /> <path d="M21 21l-4.35-4.35" /> </svg> );}.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem 2rem; text-align: center;}
.icon { margin-bottom: 1.5rem; color: var(--color-text-muted);}
.title { margin: 0 0 0.5rem; font-size: 1.25rem; font-weight: 600;}
.message { margin: 0 0 1.5rem; color: var(--color-text-muted); max-width: 300px;}Loading Skeleton Grid
Show placeholders while loading:
import styles from './GridSkeleton.module.css';
interface GridSkeletonProps { count?: number; // Number of skeleton cards to show. columns?: 2 | 3 | 4; // Grid column count to match actual grid.}
/** * GridSkeleton displays placeholder cards while products are loading. * Maintains layout stability and provides visual feedback during async operations. */export function GridSkeleton({ count = 8, columns = 4 }: GridSkeletonProps) { return ( // Match the same grid structure as ProductGrid for layout consistency. <div className={styles.grid} data-columns={columns}> {/* Generate skeleton cards */} {Array.from({ length: count }).map((_, index) => ( <div key={index} className={styles.card}> {/* Image placeholder with pulse animation */} <div className={styles.image} /> <div className={styles.content}> {/* Title placeholder - 80% width for visual variation */} <div className={styles.title} /> {/* Price placeholder - 40% width */} <div className={styles.price} /> </div> </div> ))} </div> );}.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(var(--grid-columns, 4), 1fr);}
.grid[data-columns="2"] { --grid-columns: 2; }.grid[data-columns="3"] { --grid-columns: 3; }.grid[data-columns="4"] { --grid-columns: 4; }
@media (max-width: 1023px) { .grid { --grid-columns: 3; }}
@media (max-width: 767px) { .grid { --grid-columns: 2; }}
.card { display: flex; flex-direction: column; gap: 0.75rem;}
.image { aspect-ratio: 3 / 4; background: var(--color-background-subtle); border-radius: 8px; animation: pulse 1.5s ease-in-out infinite;}
.content { display: flex; flex-direction: column; gap: 0.5rem;}
.title { height: 1rem; width: 80%; background: var(--color-background-subtle); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite;}
.price { height: 1rem; width: 40%; background: var(--color-background-subtle); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite;}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; }}Infinite Scroll Alternative
Instead of pagination, load more products on scroll:
import { useEffect, useRef, useCallback } from 'react';import { useCollection } from '@/stores/collection';import type { CollectionProduct } from '@/types/collection';import { ProductCard } from '../ProductCard';import { Spinner } from '@/components/ui';import styles from './ProductGrid.module.css';
interface InfiniteProductGridProps { products: CollectionProduct[]; // All loaded products (accumulates as user scrolls).}
/** * InfiniteProductGrid implements infinite scroll pagination. * Automatically loads more products as the user approaches the bottom. */export function InfiniteProductGrid({ products }: InfiniteProductGridProps) { const gridColumns = useCollection((state) => state.gridColumns); const isLoading = useCollection((state) => state.isLoading); const hasNextPage = useCollection((state) => state.hasNextPage); const loadMore = useCollection((state) => state.loadMore);
// Refs for IntersectionObserver setup. const observerRef = useRef<IntersectionObserver | null>(null); const loadMoreRef = useRef<HTMLDivElement>(null); // Sentinel element.
// Callback for IntersectionObserver - triggers loadMore when sentinel is visible. const handleObserver = useCallback( (entries: IntersectionObserverEntry[]) => { const [entry] = entries; // Only load more if: sentinel is visible, more pages exist, and not already loading. if (entry.isIntersecting && hasNextPage && !isLoading) { loadMore(); } }, [hasNextPage, isLoading, loadMore] );
// Set up IntersectionObserver on mount. useEffect(() => { const element = loadMoreRef.current; if (!element) return;
// Create observer with rootMargin to trigger loading before sentinel is visible. // This provides a smoother experience by preloading content. observerRef.current = new IntersectionObserver(handleObserver, { root: null, // Use viewport as root. rootMargin: '200px', // Start loading 200px before sentinel enters viewport. threshold: 0, // Trigger as soon as any part is visible. });
observerRef.current.observe(element);
// Cleanup: disconnect observer when component unmounts. return () => { if (observerRef.current) { observerRef.current.disconnect(); } }; }, [handleObserver]);
return ( <div className={styles.container}> <div className={styles.grid} data-columns={gridColumns}> {products.map((product, index) => ( <ProductCard key={product.id} product={product} priority={index < 4} // Only prioritize first batch. /> ))} </div>
{/* Sentinel element - when this enters viewport, load more is triggered */} <div ref={loadMoreRef} className={styles.loadMore}> {isLoading && <Spinner />} </div> </div> );}CSS Grid vs Flexbox
For product grids, CSS Grid is the better choice:
/* CSS Grid - Equal width columns */.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.5rem;}
/* Flexbox - Variable width, wrap issues */.flexGrid { display: flex; flex-wrap: wrap; gap: 1.5rem;}
.flexGrid > * { flex: 0 0 calc(25% - 1.125rem); /* Tricky calculation */}Why CSS Grid wins:
- Equal-width columns without calculations
- Built-in gap handling
- No orphan issues on the last row
- Easier responsive breakpoints
Key Takeaways
- CSS Grid for layouts: Use
grid-template-columns: repeat(n, 1fr) - Data attributes for columns:
data-columnsfor dynamic column count - User preference: Let users control grid density
- Persist preference: Store in localStorage
- Empty states: Clear messaging when no products match
- Loading skeletons: Maintain layout during loads
- Priority loading: Eager load first row of images
In the next lesson, we’ll build the Filtering UI with a sidebar and active filter chips.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...