ecomcn
All blocks

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}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/product-grid

Step 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.

ProductGridProps
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

Source

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 &ldquo;no results&rdquo;.
          </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>
  )
}