Browse/block
Product grid
Responsive catalogue grid with box-matched loading skeletons, appended skeletons while more results load, a density switch, a renderCard slot for composed cards, and a left-aligned empty state.
Step 1Register the namespace — once per project
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonStep 2Add the block
$ npx shadcn@latest add @ecomcn/product-gridStep 1 is not optional for this one — it pulls another ecomcn block, and the CLI has no way to resolve @ecomcn/… until the namespace is registered.
Preview — live
100%
Usage
Composed cards, more on the way<ProductGrid
id="results" // LoadMore moves focus into this list
products={products}
loading={isFetchingFirstPage} // full skeleton grid
loadingMore={isFetchingNextPage} // skeletons appended after the products
loadingMoreCount={24}
renderCard={(product) => (
<ProductCard product={product}>
<ProductCardMedia />
<ProductCardBody />
</ProductCard>
)}
/>Props
Read straight out of the source at build time, so this cannot drift from the file you install.
ProductGridPropsproducts: ProductCardProduct[]
loading?: boolean
/** How many skeletons to render while `loading`. Match your page size. */
skeletonCount?: number
density?: "comfortable" | "compact"
currency?: string
locale?: string
onQuickAdd?: ProductCardProps["onQuickAdd"]
/** Rendered instead of the grid when `products` is empty and not loading. */
empty?: React.ReactNode
onResetFilters?: () => void
/**
* Render each product yourself — typically a ProductCard composed from its
* parts. Return one element per product: it becomes a direct grid child.
*/
renderCard?: (product: ProductCardProduct, index: number) => React.ReactNode
/** More results are on their way: append skeletons after the current products. */
loadingMore?: boolean
/** How many skeletons to append while `loadingMore`. Match your page size. */
loadingMoreCount?: numberSource
components/ecomcn/product-grid.tsx"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
ProductCard,
type ProductCardProduct,
type ProductCardProps,
} from "@/components/ecomcn/product-card"
import { cn } from "@/lib/utils"
export interface ProductGridProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
products: ProductCardProduct[]
loading?: boolean
/** How many skeletons to render while `loading`. Match your page size. */
skeletonCount?: number
density?: "comfortable" | "compact"
currency?: string
locale?: string
onQuickAdd?: ProductCardProps["onQuickAdd"]
/** Rendered instead of the grid when `products` is empty and not loading. */
empty?: React.ReactNode
onResetFilters?: () => void
/**
* Render each product yourself — typically a ProductCard composed from its
* parts. Return one element per product: it becomes a direct grid child.
*/
renderCard?: (product: ProductCardProduct, index: number) => React.ReactNode
/** More results are on their way: append skeletons after the current products. */
loadingMore?: boolean
/** How many skeletons to append while `loadingMore`. Match your page size. */
loadingMoreCount?: number
}
// Skeleton boxes must match the real card exactly or the grid jumps: same
// aspect ratio, same four text bars.
function CardSkeleton() {
return (
<div aria-hidden data-slot="product-grid-skeleton">
<Skeleton className="aspect-[4/5] w-full rounded-none" />
<Skeleton className="mt-3.5 h-3 w-16 rounded-none" />
<Skeleton className="mt-2 h-5 w-3/4 rounded-none" />
<Skeleton className="mt-2 h-3 w-14 rounded-none" />
</div>
)
}
export function ProductGrid({
products,
loading,
skeletonCount = 6,
density = "comfortable",
currency,
locale,
onQuickAdd,
empty,
onResetFilters,
renderCard,
loadingMore,
loadingMoreCount = 3,
className,
...props
}: ProductGridProps) {
// Column gap tighter than row gap: products group by row, like a printed page.
// Compact adds a column from `sm` up, so a density switch always does something.
const grid = cn(
"grid grid-cols-2 gap-x-5 gap-y-9 lg:grid-cols-3",
density === "compact" && "gap-y-7 sm:grid-cols-3 lg:grid-cols-4",
className
)
if (loading) {
return (
<div className={grid} aria-busy="true" data-slot="product-grid" {...props}>
{Array.from({ length: skeletonCount }).map((_, i) => (
<CardSkeleton key={i} />
))}
<span className="sr-only">Loading products</span>
</div>
)
}
if (products.length === 0) {
return (
empty ?? (
<div className="flex flex-col items-start gap-5 border py-14 pl-8 pr-6 sm:pl-14">
<h3 className="text-3xl">Nothing under these filters.</h3>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground">
Be specific here in your own copy — naming the filter that emptied the
grid converts far better than the words “no results”.
</p>
{onResetFilters ? (
<Button onClick={onResetFilters} className="h-10 text-[13px]">
Reset filters
</Button>
) : null}
</div>
)
)
}
return (
<div className={grid} aria-busy={loadingMore || undefined} data-slot="product-grid" {...props}>
{products.map((product, index) =>
renderCard ? (
<React.Fragment key={product.id}>{renderCard(product, index)}</React.Fragment>
) : (
<ProductCard
key={product.id}
product={product}
density={density}
currency={currency}
locale={locale}
onQuickAdd={onQuickAdd}
/>
)
)}
{loadingMore
? Array.from({ length: loadingMoreCount }).map((_, i) => <CardSkeleton key={`more-${i}`} />)
: null}
</div>
)
}