# Product Card

> Product tile with badge, star rating, strikethrough price, an Add to cart toggle and a wishlist heart.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/product-card
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/product-card.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/product-card-demo.tsx"
import { ProductCard } from "@/components/product-card"

export default function ProductCardDemo() {
  return (
    <div className="flex flex-wrap items-start justify-center gap-6">
      <ProductCard
        name="Linen Overshirt"
        price={64}
        rating={4}
        reviews={128}
        badge="New"
      />
      <ProductCard
        name="Ceramic Pour-over Set"
        price={39.2}
        originalPrice={49}
        rating={5}
        reviews={342}
        badge="-20%"
        imageClassName="from-sky-300 via-indigo-400 to-violet-500"
      />
    </div>
  )
}
```

## Source

```tsx title="components/product-card.tsx"
"use client"

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

import { cn } from "@/lib/utils"

export interface ProductCardProps extends Omit<React.ComponentProps<"div">, "title"> {
  name: string
  price: number
  /** Original price, shown with a strikethrough */
  originalPrice?: number
  /** 0-5, supports halves visually by rounding */
  rating: number
  reviews?: number
  /** e.g. "New" or "-20%" */
  badge?: string
  /** Tailwind gradient utilities for the image placeholder */
  imageClassName?: string
  currency?: string
  onAddToCart?: (added: boolean) => void
  onWishlistChange?: (wished: boolean) => void
}

export function ProductCard({
  name,
  price,
  originalPrice,
  rating,
  reviews,
  badge,
  imageClassName = "from-amber-300 via-orange-400 to-rose-500",
  currency = "$",
  onAddToCart,
  onWishlistChange,
  className,
  ...props
}: ProductCardProps) {
  const [added, setAdded] = React.useState(false)
  const [wished, setWished] = React.useState(false)
  const filled = Math.round(rating)

  return (
    <div
      className={cn(
        "group w-full max-w-[16rem] overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md",
        className
      )}
      {...props}
    >
      <div className={cn("relative aspect-square bg-gradient-to-br", imageClassName)}>
        <div
          className="absolute inset-0 flex items-center justify-center text-white/80 transition-transform duration-300 group-hover:scale-110 motion-reduce:transition-none"
          aria-hidden
        >
          <ShoppingBag className="size-14" strokeWidth={1.25} />
        </div>
        {badge && (
          <span className="absolute left-3 top-3 rounded-full bg-background px-2.5 py-1 text-xs font-semibold text-foreground shadow-sm">
            {badge}
          </span>
        )}
        <button
          type="button"
          aria-pressed={wished}
          aria-label={wished ? `Remove ${name} from wishlist` : `Add ${name} to wishlist`}
          onClick={() => {
            const next = !wished
            setWished(next)
            onWishlistChange?.(next)
          }}
          className="absolute right-3 top-3 flex size-9 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm outline-none backdrop-blur transition-colors hover:bg-background focus-visible:ring-4 focus-visible:ring-ring/40"
        >
          <Heart
            className={cn(
              "size-4 transition-colors",
              wished && "fill-rose-500 text-rose-500"
            )}
            aria-hidden
          />
        </button>
      </div>
      <div className="space-y-3 p-4">
        <div className="space-y-1.5">
          <h3 className="truncate text-sm font-medium">{name}</h3>
          <div className="flex items-center gap-1.5">
            <div className="flex" role="img" aria-label={`Rated ${rating} out of 5`}>
              {Array.from({ length: 5 }, (_, i) => (
                <Star
                  key={i}
                  className={cn(
                    "size-3.5",
                    i < filled ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"
                  )}
                  aria-hidden
                />
              ))}
            </div>
            {reviews !== undefined && (
              <span className="text-xs text-muted-foreground">({reviews})</span>
            )}
          </div>
        </div>
        <div className="flex items-baseline gap-2">
          <span className="text-lg font-semibold tabular-nums">
            {currency}
            {price.toFixed(2)}
          </span>
          {originalPrice !== undefined && (
            <span className="text-sm text-muted-foreground tabular-nums line-through">
              {currency}
              {originalPrice.toFixed(2)}
            </span>
          )}
        </div>
        <button
          type="button"
          aria-live="polite"
          onClick={() => {
            const next = !added
            setAdded(next)
            onAddToCart?.(next)
          }}
          className={cn(
            "inline-flex h-10 w-full items-center justify-center gap-2 rounded-xl text-sm font-medium outline-none transition-colors focus-visible:ring-4 focus-visible:ring-ring/40",
            added
              ? "border bg-secondary text-secondary-foreground hover:bg-secondary/80"
              : "bg-primary text-primary-foreground hover:opacity-90"
          )}
        >
          {added ? (
            <>
              <Check className="size-4" aria-hidden /> Added
            </>
          ) : (
            <>
              <ShoppingBag className="size-4" aria-hidden /> Add to cart
            </>
          )}
        </button>
      </div>
    </div>
  )
}
```
