Collection Page Components Intermediate 10 min read

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/StateSourceLiquid Field
productsParent (CollectionPage) / Zustand storecollection.products (array)
gridColumnsZustand store- (user preference, localStorage)
activeFiltersZustand store- (derived from URL params)
isLoadingZustand store-
hasNextPageZustand storeDerived from paginate.pages

Grid Layout Strategy

Desktop (4 columns) Tablet (3 columns) Mobile (2 columns)
┌────┬────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┐
│ │ │ │ │ │ │ │ │ │ │ │
├────┼────┼────┼────┤ ├────┼────┼────┤ ├────┼────┤
│ │ │ │ │ │ │ │ │ │ │ │
├────┼────┼────┼────┤ ├────┼────┼────┤ ├────┼────┤
│ │ │ │ │ │ │ │ │ │ │ │
└────┴────┴────┴────┘ └────┴────┴────┘ └────┴────┘

The Product Grid Component

src/components/collection/ProductGrid/ProductGrid.tsx
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

src/components/collection/ProductGrid/ProductGrid.module.css
.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:

src/components/collection/ProductGrid/GridControls.tsx
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>
);
}
src/components/collection/ProductGrid/GridControls.module.css
.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:

src/components/collection/ProductGrid/EmptyState.tsx
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>
);
}
src/components/collection/ProductGrid/EmptyState.module.css
.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:

src/components/collection/ProductGrid/GridSkeleton.tsx
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>
);
}
src/components/collection/ProductGrid/GridSkeleton.module.css
.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:

src/components/collection/ProductGrid/InfiniteProductGrid.tsx
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

  1. CSS Grid for layouts: Use grid-template-columns: repeat(n, 1fr)
  2. Data attributes for columns: data-columns for dynamic column count
  3. User preference: Let users control grid density
  4. Persist preference: Store in localStorage
  5. Empty states: Clear messaging when no products match
  6. Loading skeletons: Maintain layout during loads
  7. 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...