ProductList
Description
Displays an array of products in grid or list layout. Includes loading skeleton, empty state, and customizable rendering. Supports responsive columns and add to cart functionality.
Import
import { ProductList } from '@components/frontStore/catalog/ProductList';
Usage
import { ProductList } from '@components/frontStore/catalog/ProductList';
function CategoryPage({ products }) {
return (
<ProductList
products={products}
layout="grid"
gridColumns={4}
/>
);
}
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| products | ProductData[] | Yes | [] | Array of products to display |
| imageWidth | number | No | 800 (grid) / 320 (list) | Base target width in pixels. The height is derived, not passed — see below. |
| isLoading | boolean | No | false | Show loading skeleton |
| emptyMessage | string | ReactNode | No | 'No products found' | Empty state message |
| className | string | No | '' | Additional CSS classes |
| layout | 'grid' | 'list' | No | 'grid' | Display layout mode |
| gridColumns | number | No | 4 | Number of columns (1-6) |
| showAddToCart | boolean | No | true | Offer an Add to Cart button on each card. Leave it out: ProductList decides, and a theme changes it everywhere by overriding this component. Pass false only for a list that must never have the button. It is offered only inside the storefront's cart provider. |
| customAddToCartRenderer | (product) => ReactNode | No | - | Custom add to cart renderer |
| renderItem | (product) => ReactNode | No | - | Custom product item renderer |
Image Sizing
ProductList takes no imageHeight prop. The height comes from the store's configured original product image dimensions (the admin Catalog setting, surfaced to the client as config.catalog.imageDimensions), so every placement renders at the store's true aspect ratio:
const catalogDimensions = useCatalogImageDimensions();
const baseWidth = imageWidth ?? (layout === 'list' ? 320 : 800);
const { width, height } = deriveProductImageSize(baseWidth, catalogDimensions);
Two consequences worth knowing:
- The defaults are ~2× the CSS display width, deliberately — DPR-2 / retina screens stay sharp and the
<Image>srcsetcovers the rest.800for grid,320for list. They are not the rendered pixel size. deriveProductImageSizeclamps the requested width to the store's original width, so it never asks for an upscale. When no usable original is configured it falls back to a square box at the requested width (the fallback original is1200 × 1200).
Pass imageWidth only when a placement genuinely needs a different resolution ceiling — a narrow sidebar shelf, for example. To change the aspect ratio for the whole store, change the Catalog image setting rather than the component.
imageHeight no longer exists on ProductList. Passing it is a no-op: React drops the unknown prop and the height is derived regardless. If your theme still passes imageWidth/imageHeight as a matched square pair, drop the height and let the store's aspect ratio drive it.
Examples
Basic Grid Layout
import { ProductList } from '@components/frontStore/catalog/ProductList';
function Products({ products }) {
return (
<ProductList
products={products}
layout="grid"
gridColumns={3}
/>
);
}
List Layout
import { ProductList } from '@components/frontStore/catalog/ProductList';
function SearchResults({ products }) {
return (
<ProductList
products={products}
layout="list"
/>
);
}
The list layout already defaults imageWidth to 320. Override it only to change the resolution ceiling — the height still follows the store's aspect ratio.
With Loading State
import { ProductList } from '@components/frontStore/catalog/ProductList';
import { useState, useEffect } from 'react';
function CategoryPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProducts().then(data => {
setProducts(data);
setLoading(false);
});
}, []);
return (
<ProductList
products={products}
isLoading={loading}
gridColumns={4}
/>
);
}
With Add to Cart (the default)
import { ProductList } from '@components/frontStore/catalog/ProductList';
function ShopPage({ products }) {
return (
<ProductList
products={products}
gridColumns={4}
/>
);
}
Custom Empty Message
import { ProductList } from '@components/frontStore/catalog/ProductList';
function SearchResults({ products, searchTerm }) {
return (
<ProductList
products={products}
emptyMessage={
<div>
<p>No results found for "{searchTerm}"</p>
<a href="/products">Browse all products</a>
</div>
}
/>
);
}
Custom Product Renderer
import { ProductList } from '@components/frontStore/catalog/ProductList';
import { Image } from '@components/common/Image';
function FeaturedProducts({ products }) {
return (
<ProductList
products={products}
gridColumns={3}
renderItem={(product) => (
<div className="featured-product">
<div className="badge">Featured</div>
{product.image && (
<Image
src={product.image.url}
alt={product.name}
width={300}
height={300}
/>
)}
<h3>{product.name}</h3>
<p className="price">{product.price.regular.text}</p>
<a href={product.url}>View Details</a>
</div>
)}
/>
);
}
Custom Add to Cart Button
import { ProductList } from '@components/frontStore/catalog/ProductList';
import { AddToCart } from '@components/frontStore/cart/AddToCart';
function ProductGrid({ products }) {
return (
<ProductList
products={products}
customAddToCartRenderer={(product) => (
<AddToCart
product={{ sku: product.sku, isInStock: product.inventory.isInStock }}
qty={1}
>
{/* children is (state, actions) => ReactNode — two arguments,
not one destructured object. */}
{(state, actions) => (
<button
onClick={actions.addToCart}
disabled={!state.canAddToCart}
className="custom-add-btn"
>
{state.isLoading ? 'Adding...' : 'Quick Add'}
</button>
)}
</AddToCart>
)}
/>
);
}
Complete Example
import { ProductList } from '@components/frontStore/catalog/ProductList';
import { useCategory } from '@components/frontStore/catalog/CategoryContext';
import { useState } from 'react';
function CategoryProducts() {
const category = useCategory();
const [layout, setLayout] = useState<'grid' | 'list'>('grid');
const [columns, setColumns] = useState(4);
return (
<div className="category-products">
{/* Controls */}
<div className="controls">
<div className="layout-toggle">
<button
onClick={() => setLayout('grid')}
className={layout === 'grid' ? 'active' : ''}
>
Grid
</button>
<button
onClick={() => setLayout('list')}
className={layout === 'list' ? 'active' : ''}
>
List
</button>
</div>
{layout === 'grid' && (
<select value={columns} onChange={(e) => setColumns(Number(e.target.value))}>
<option value={2}>2 Columns</option>
<option value={3}>3 Columns</option>
<option value={4}>4 Columns</option>
<option value={5}>5 Columns</option>
</select>
)}
<span>{category.products.total} products</span>
</div>
{/* Product List */}
<ProductList
products={category.products.items}
layout={layout}
gridColumns={columns}
emptyMessage="No products in this category"
className="mt-8"
/>
</div>
);
}
Showing or hiding Add to Cart
ProductList is the one place that decides. The category, search and all-products pages, the "Collection products" and "Collection stack" widgets, the recommendation shelves, the collection spotlight and a rich-text product block all leave showAddToCart out, so they all follow it. To change it for the whole storefront, override this component in your theme (the @components alias looks in the theme first) and change the default:
// themes/<your-theme>/src/components/frontStore/catalog/ProductList.tsx
// A copy of core's ProductList.tsx with one line changed:
showAddToCart = false,
Three rules the component keeps for you:
- An extension that needs one list to differ can still opt out for it:
<ProductList showAddToCart={false} />. No list in core does. - A product with variants (colour, size...) gets "Select options", a link to its page, instead of "Add to Cart": the customer has to choose, and a card cannot ask. ProductListAddToCart makes that choice. A list of your own must select
variantGroupIdin itsProductfragment for it to work. - The button is offered only where a cart exists.
AddToCartneeds the storefront's cart provider and throws without one, and a widget can be placed in an area above it, so outside the provider the list simply has no button.
Grid Columns
Responsive grid columns based on gridColumns prop:
- 1: Single column (all screens)
- 2: 1 column mobile, 2 columns desktop
- 3: 1 mobile, 2 tablet, 3 desktop
- 4: 1 mobile, 2 tablet, 4 desktop (default)
- 5: 1 mobile, 2 tablet, 5 desktop
- 6: 1 mobile, 2 tablet, 6 desktop
List Layout
In list layout:
- Products displayed vertically
- Full width items
imageWidthdefaults to320instead of800; the height still follows the store's configured aspect ratio
States
Loading State
Shows skeleton placeholders matching the layout and column count.
Empty State
Displays emptyMessage when no products exist.
Loaded State
Renders product items using default or custom renderer.
Features
- Flexible Layout: Grid or list display
- Responsive Grid: Auto-adjusts columns for mobile/tablet/desktop
- Loading Skeleton: Animated placeholders
- Empty State: Customizable no-results message
- Custom Rendering: Full control over product display
- Add to Cart: On by default, built-in or custom; decided here, overridable in one place
- Image Sizing: Height derived from the store's configured aspect ratio;
imageWidthoverridable per placement - Type Safe: Full TypeScript support
Related Components
- ProductContext - Product data interface
- CategoryContext - Category page context
- AddToCart - Add to cart component
- ProductListAddToCart - The action under each card (Add to Cart, or Select options for variants)
- Image - Image component