ecomcn
All blocks

Browse/block

Product card

Catalogue tile with badges, compare-at pricing, colour swatches that swap the image, rating and a keyboard-reachable quick-add. Compound: use it as one tag, or recompose it from ProductCardMedia, ProductCardBody and their parts via useProductCard().

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-card

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

One tag
import { ProductCard } from "@/components/ecomcn/product-card"

<ProductCard
  product={{
    id: "p1",
    name: "Field Tote 24L",
    href: "/products/field-tote",
    price: 195,
    compareAt: 240,
    image: <img src="/tote.jpg" alt="" className="size-full object-cover" />,
    colors: [
      // Each colourway can carry its own image; the swatch swaps it.
      { name: "Olive", hex: "#5d6446", image: <img src="/tote-olive.jpg" alt="" /> },
      { name: "Black", hex: "#1c1b1a", image: <img src="/tote-black.jpg" alt="" /> },
    ],
  }}
  onQuickAdd={(product, colorIndex) => addToBag(product.id, colorIndex)}
/>
Composed from parts
import {
  ProductCard,
  ProductCardBody,
  ProductCardImage,
  ProductCardMedia,
  ProductCardPrice,
  ProductCardQuickAdd,
  ProductCardSwatches,
  ProductCardTitle,
  useProductCard,
} from "@/components/ecomcn/product-card"

<ProductCard product={product} onQuickAdd={add}>
  <ProductCardMedia>
    <ProductCardImage />
    <ProductCardQuickAdd />
  </ProductCardMedia>
  <ProductCardBody>
    <ProductCardTitle />
    <LowStock />
    <ProductCardSwatches />
    <ProductCardPrice />
  </ProductCardBody>
</ProductCard>

// Parts share the card's state — the product, the selected colour, quick-add.
function LowStock() {
  const { color } = useProductCard()
  return color?.name === "Olive" ? <p className="text-xs">Only 2 left in Olive</p> : null
}

Props & context

Read straight out of the source at build time, so this cannot drift from the file you install.

ProductCardContextValue
product: ProductCardProduct
  currency: string
  locale?: string
  density: "comfortable" | "compact"
  colorIndex: number
  setColorIndex: (index: number) => void
  /** The selected colour, if the product has any. */
  color?: ProductCardColor
  /** The selected colour's image, falling back to the product image. */
  image: React.ReactNode
  /** Undefined when the card was given no `onQuickAdd`. */
  quickAdd?: () => void
  quickAddState: QuickAddState
ProductCardProps
product: ProductCardProduct
  currency?: string
  locale?: string
  density?: "comfortable" | "compact"
  /** Return a promise to keep the button in its pending state until settled. */
  onQuickAdd?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>
  /** Controlled selected colour. */
  colorIndex?: number
  defaultColorIndex?: number
  onColorChange?: (color: ProductCardColor, index: number) => void
  /**
   * Compose your own card from ProductCardMedia, ProductCardBody and their
   * parts. Leave empty for the default layout.
   */
  children?: React.ReactNode

Source

components/ecomcn/product-card.tsx
"use client"

import * as React from "react"
import { Check, Plus, Star } from "lucide-react"

import { Button } from "@/components/ui/button"
import { PriceTag } from "@/components/ecomcn/price-tag"
import { cn } from "@/lib/utils"

export interface ProductCardColor {
  name: string
  /** Any CSS colour, or a swatch image URL via `background`. */
  hex: string
  /** Shown in the media frame while this colour is selected. */
  image?: React.ReactNode
}

export interface ProductCardProduct {
  id: string
  name: string
  href: string
  brand?: string
  price: number
  compareAt?: number
  /** Already-rendered image element, so this block never assumes next/image. */
  image?: React.ReactNode
  badge?: string
  colors?: ProductCardColor[]
  rating?: number
  reviewCount?: number
}

/* ─── context ──────────────────────────────────────────────────────────────
 * Compound component: <ProductCard> holds the selected colour and the
 * quick-add state, and every part reads them through useProductCard(). That
 * is what lets a swatch change the image without either knowing the other.
 */

type QuickAddState = "idle" | "pending" | "added"

export interface ProductCardContextValue {
  product: ProductCardProduct
  currency: string
  locale?: string
  density: "comfortable" | "compact"
  colorIndex: number
  setColorIndex: (index: number) => void
  /** The selected colour, if the product has any. */
  color?: ProductCardColor
  /** The selected colour's image, falling back to the product image. */
  image: React.ReactNode
  /** Undefined when the card was given no `onQuickAdd`. */
  quickAdd?: () => void
  quickAddState: QuickAddState
}

const ProductCardContext = React.createContext<ProductCardContextValue | null>(null)

export function useProductCard() {
  const context = React.useContext(ProductCardContext)
  if (!context) {
    throw new Error("useProductCard must be used within <ProductCard>.")
  }
  return context
}

/** Controlled when `value` is passed, uncontrolled from `defaultValue` otherwise. */
function useControllableState<T>(
  value: T | undefined,
  defaultValue: T,
  onChange?: (next: T) => void
) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
  const controlled = value !== undefined
  const set = React.useCallback(
    (next: T) => {
      if (!controlled) setUncontrolled(next)
      onChange?.(next)
    },
    [controlled, onChange]
  )
  return [controlled ? value : uncontrolled, set] as const
}

/* ─── root ─────────────────────────────────────────────────────────────── */

export interface ProductCardProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "onSelect"> {
  product: ProductCardProduct
  currency?: string
  locale?: string
  density?: "comfortable" | "compact"
  /** Return a promise to keep the button in its pending state until settled. */
  onQuickAdd?: (product: ProductCardProduct, colorIndex: number) => void | Promise<void>
  /** Controlled selected colour. */
  colorIndex?: number
  defaultColorIndex?: number
  onColorChange?: (color: ProductCardColor, index: number) => void
  /**
   * Compose your own card from ProductCardMedia, ProductCardBody and their
   * parts. Leave empty for the default layout.
   */
  children?: React.ReactNode
}

export function ProductCard({
  product,
  currency = "USD",
  locale,
  density = "comfortable",
  onQuickAdd,
  colorIndex: colorIndexProp,
  defaultColorIndex = 0,
  onColorChange,
  className,
  children,
  ...props
}: ProductCardProps) {
  const colors = product.colors
  const handleColorChange = React.useCallback(
    (index: number) => {
      const color = colors?.[index]
      if (color) onColorChange?.(color, index)
    },
    [colors, onColorChange]
  )
  const [colorIndex, setColorIndex] = useControllableState(
    colorIndexProp,
    defaultColorIndex,
    handleColorChange
  )

  const [quickAddState, setQuickAddState] = React.useState<QuickAddState>("idle")
  const resetTimer = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(resetTimer.current), [])

  const quickAdd = React.useMemo(() => {
    if (!onQuickAdd) return undefined
    return async () => {
      if (quickAddState === "pending") return
      setQuickAddState("pending")
      try {
        await onQuickAdd(product, colorIndex)
        setQuickAddState("added")
        window.clearTimeout(resetTimer.current)
        resetTimer.current = window.setTimeout(() => setQuickAddState("idle"), 2000)
      } catch {
        setQuickAddState("idle")
      }
    }
  }, [onQuickAdd, quickAddState, product, colorIndex])

  const color = product.colors?.[colorIndex]
  const context = React.useMemo<ProductCardContextValue>(
    () => ({
      product,
      currency,
      locale,
      density,
      colorIndex,
      setColorIndex,
      color,
      image: color?.image ?? product.image,
      quickAdd,
      quickAddState,
    }),
    [product, currency, locale, density, colorIndex, setColorIndex, color, quickAdd, quickAddState]
  )

  return (
    <ProductCardContext.Provider value={context}>
      <article
        data-slot="product-card"
        className={cn("group relative flex flex-col", className)}
        {...props}
      >
        {children ?? (
          <>
            <ProductCardMedia />
            <ProductCardBody />
          </>
        )}
      </article>
    </ProductCardContext.Provider>
  )
}

/* ─── media ────────────────────────────────────────────────────────────── */

/** The 4:5 frame. Defaults to image, badge and quick-add; pass children to recompose. */
export function ProductCardMedia({
  className,
  children,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  useProductCard()
  return (
    <div
      data-slot="product-card-media"
      className={cn("relative aspect-[4/5] overflow-hidden bg-secondary", className)}
      {...props}
    >
      {children ?? (
        <>
          <ProductCardImage />
          <ProductCardBadge />
          <ProductCardQuickAdd />
        </>
      )}
    </div>
  )
}

/** Fills the media frame with the selected colour's image. */
export function ProductCardImage({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { image } = useProductCard()
  return (
    <div data-slot="product-card-image" className={cn("absolute inset-0", className)} {...props}>
      {image}
    </div>
  )
}

export function ProductCardBadge({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
  const { product } = useProductCard()
  if (!product.badge) return null
  return (
    <span
      data-slot="product-card-badge"
      className={cn(
        "absolute top-0 left-0 bg-foreground px-2.5 py-1.5 text-[11px] tracking-[0.16em] text-background uppercase",
        className
      )}
      {...props}
    >
      {product.badge}
    </span>
  )
}

export function ProductCardQuickAdd({ className }: { className?: string }) {
  const { quickAdd, quickAddState } = useProductCard()
  if (!quickAdd) return null

  return (
    // focus-visible keeps this reachable without a pointer; the card link
    // sits behind it via the ::after overlay on the title anchor.
    <Button
      type="button"
      data-slot="product-card-quick-add"
      onClick={quickAdd}
      disabled={quickAddState === "pending"}
      className={cn(
        "absolute inset-x-0 bottom-0 z-10 h-11 translate-y-full rounded-none text-[12px] tracking-[0.14em] transition-transform duration-200 motion-reduce:transition-none",
        "group-hover:translate-y-0 focus-visible:translate-y-0",
        quickAddState === "added" &&
          "translate-y-0 bg-[var(--success)] hover:bg-[var(--success)]",
        className
      )}
    >
      {quickAddState === "added" ? (
        <>
          <Check className="mr-1.5 size-3.5" aria-hidden /> IN BAG
        </>
      ) : (
        <>
          <Plus className="mr-1.5 size-3.5" aria-hidden />
          {quickAddState === "pending" ? "ADDING…" : "QUICK ADD"}
        </>
      )}
    </Button>
  )
}

/* ─── body ─────────────────────────────────────────────────────────────── */

/** The text column. Defaults to brand, title, price and meta; pass children to recompose. */
export function ProductCardBody({
  className,
  children,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  const { density } = useProductCard()
  return (
    <div
      data-slot="product-card-body"
      className={cn("flex flex-1 flex-col", density === "compact" ? "pt-2.5" : "pt-3.5", className)}
      {...props}
    >
      {children ?? (
        <>
          <ProductCardBrand />
          <ProductCardTitle />
          <ProductCardPrice />
          <ProductCardMeta />
        </>
      )}
    </div>
  )
}

export function ProductCardBrand({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
  const { product } = useProductCard()
  if (!product.brand) return null
  return (
    <p
      data-slot="product-card-brand"
      className={cn("text-[11px] tracking-[0.18em] text-muted-foreground uppercase", className)}
      {...props}
    >
      {product.brand}
    </p>
  )
}

export function ProductCardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
  const { product } = useProductCard()
  return (
    <h3 data-slot="product-card-title" className={cn("mt-1 text-xl leading-tight", className)} {...props}>
      {/* One link target for the whole card via the ::after overlay — no
          nested anchors. Anything interactive on top needs `relative z-10`. */}
      <a href={product.href} className="after:absolute after:inset-0">
        {product.name}
      </a>
    </h3>
  )
}

export function ProductCardPrice({ className }: { className?: string }) {
  const { product, currency, locale } = useProductCard()
  return (
    <PriceTag
      price={product.price}
      compareAt={product.compareAt}
      currency={currency}
      locale={locale}
      size="sm"
      className={cn("mt-1.5", className)}
    />
  )
}

/** Swatches and rating on one line. Hidden in the compact density. */
export function ProductCardMeta({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { product, density } = useProductCard()
  if (density === "compact") return null
  if (!children && !product.colors?.length && product.rating === undefined) return null
  return (
    <div
      data-slot="product-card-meta"
      className={cn("mt-3 flex items-center justify-between gap-3", className)}
      {...props}
    >
      {children ?? (
        <>
          <ProductCardSwatches />
          <ProductCardRating />
        </>
      )}
    </div>
  )
}

export function ProductCardSwatches({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const { product, colorIndex, setColorIndex } = useProductCard()
  if (!product.colors?.length) return <span />
  return (
    <div
      role="group"
      aria-label="Colour"
      data-slot="product-card-swatches"
      className={cn("relative z-10 flex gap-1.5", className)}
      {...props}
    >
      {product.colors.map((color, i) => (
        <button
          key={color.name}
          type="button"
          aria-label={color.name}
          aria-pressed={i === colorIndex}
          onClick={() => setColorIndex(i)}
          className={cn(
            "size-4 border transition-all outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            i === colorIndex
              ? "border-foreground ring-1 ring-foreground ring-offset-2 ring-offset-background"
              : "border-foreground/20 hover:border-foreground/50"
          )}
          style={{ background: color.hex }}
        />
      ))}
    </div>
  )
}

export function ProductCardRating({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
  const { product, locale } = useProductCard()
  if (product.rating === undefined) return null
  return (
    <span
      data-slot="product-card-rating"
      className={cn("flex items-center gap-1 text-xs text-muted-foreground tabular-nums", className)}
      {...props}
    >
      <Star className="size-3 fill-current" aria-hidden />
      <span className="sr-only">Rated</span>
      {new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(
        product.rating
      )}
      {product.reviewCount ? (
        <span className="opacity-60">
          ({new Intl.NumberFormat(locale).format(product.reviewCount)}
          <span className="sr-only"> reviews</span>)
        </span>
      ) : null}
    </span>
  )
}