All components

Product Card

Product Card is a React and Tailwind CSS card component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT cards

What it is

Product Card is a React and Tailwind CSS card component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i motion

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Product Card

index.tsx
"use client";

import { cn } from "@/lib/utils";
import SmoothButton from "@/components/smoothui/smooth-button";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useState } from "react";

export interface ProductCardProps {
  badge?: string;
  className?: string;
  currency?: string;
  image: string;
  onAddToCart?: () => void;
  onWishlist?: () => void;
  originalPrice?: number;
  price: number;
  rating?: number;
  title: string;
}

/* ─────────────────────────────────────────────────────────
 * ANIMATION STORYBOARD
 *
 *    0ms   card enters viewport → fade up + scale
 *  250ms   badge pops in with spring
 *  hover   image zooms 1.05, wishlist heart appears
 *  click   button scale bounce + icon morph to checkmark
 * ───────────────────────────────────────────────────────── */

const SPRING = {
  bounce: 0.1,
  duration: 0.25,
  type: "spring" as const,
};

const SPRING_BOUNCY = {
  bounce: 0.2,
  duration: 0.3,
  type: "spring" as const,
};

const STAR_PATH =
  "M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z";

function StarIcon({ filled, half }: { filled: boolean; half?: boolean }) {
  const id = useId();

  if (half) {
    const gradientId = `half-star-${id}`;
    return (
      <svg
        aria-hidden="true"
        className="h-3.5 w-3.5 text-amber-400"
        viewBox="0 0 24 24"
      >
        <defs>
          <linearGradient id={gradientId}>
            <stop offset="50%" stopColor="currentColor" />
            <stop offset="50%" stopColor="transparent" />
          </linearGradient>
        </defs>
        <path
          d={STAR_PATH}
          fill={`url(#${gradientId})`}
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={1.5}
        />
      </svg>
    );
  }

  return (
    <svg
      aria-hidden="true"
      className={cn(
        "h-3.5 w-3.5",
        filled ? "text-amber-400" : "text-muted-foreground/30"
      )}
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={filled ? 0 : 1.5}
      viewBox="0 0 24 24"
    >
      <path d={STAR_PATH} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2.5}
      viewBox="0 0 24 24"
    >
      <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function CartIcon() {
  return (
    <svg
      aria-hidden="true"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      viewBox="0 0 24 24"
    >
      <path
        d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function HeartIcon({ filled }: { filled: boolean }) {
  return (
    <svg
      aria-hidden="true"
      className={cn("h-4 w-4", filled ? "text-red-500" : "text-foreground")}
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={filled ? 0 : 2}
      viewBox="0 0 24 24"
    >
      <path
        d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function RatingStars({ rating, title }: { rating: number; title: string }) {
  const fullStars = Math.floor(rating);
  const hasHalf = rating - fullStars >= 0.25 && rating - fullStars < 0.75;
  const roundedUp = rating - fullStars >= 0.75;

  return (
    <div
      aria-label={`${rating} out of 5 stars`}
      className="flex items-center gap-0.5"
    >
      {Array.from({ length: 5 }, (_, i) => {
        const isFilled = i < fullStars || (roundedUp && i === fullStars);
        const isHalf = hasHalf && i === fullStars;
        return (
          <StarIcon
            filled={isFilled}
            half={isHalf}
            key={`star-${title}-${i}`}
          />
        );
      })}
      <span className="ml-1 font-medium text-muted-foreground text-xs">
        {rating}
      </span>
    </div>
  );
}

export default function ProductCard({
  image,
  title,
  price,
  originalPrice,
  currency = "$",
  rating,
  badge,
  onAddToCart,
  onWishlist,
  className,
}: ProductCardProps) {
  const shouldReduceMotion = useReducedMotion();
  const [isHoverDevice, setIsHoverDevice] = useState(false);
  const [isAdded, setIsAdded] = useState(false);
  const [isWishlisted, setIsWishlisted] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)");
    setIsHoverDevice(mediaQuery.matches);
    const handler = (e: MediaQueryListEvent) => setIsHoverDevice(e.matches);
    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  const handleAddToCart = () => {
    setIsAdded(true);
    onAddToCart?.();
    setTimeout(() => setIsAdded(false), 2000);
  };

  const handleWishlist = () => {
    setIsWishlisted((prev) => !prev);
    onWishlist?.();
  };

  const hasDiscount = originalPrice !== undefined && originalPrice > price;
  const discountPercent = hasDiscount
    ? Math.round(((originalPrice - price) / originalPrice) * 100)
    : 0;

  return (
    <motion.div
      aria-label={`${title} - ${currency}${price}`}
      className={cn(
        "group relative flex w-full flex-col overflow-hidden rounded-2xl border bg-card shadow-sm",
        "transition-shadow duration-300",
        isHoverDevice && "hover:shadow-black/5 hover:shadow-xl",
        className
      )}
      initial={
        shouldReduceMotion
          ? { opacity: 1 }
          : { opacity: 0, transform: "translateY(20px) scale(0.97)" }
      }
      role="article"
      transition={shouldReduceMotion ? { duration: 0 } : SPRING}
      viewport={{ margin: "-50px", once: true }}
      whileInView={
        shouldReduceMotion
          ? { opacity: 1 }
          : { opacity: 1, transform: "translateY(0px) scale(1)" }
      }
    >
      {/* Image — full bleed */}
      <div className="relative aspect-square overflow-hidden bg-muted">
        <img
          alt={title}
          className={cn(
            "h-full w-full object-cover",
            !shouldReduceMotion &&
              "transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]",
            isHoverDevice && !shouldReduceMotion && "group-hover:scale-105"
          )}
          draggable={false}
          src={image}
        />

        {/* Hover overlay gradient */}
        <div
          className={cn(
            "pointer-events-none absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-transparent opacity-0 transition-opacity duration-300",
            isHoverDevice && "group-hover:opacity-100"
          )}
        />

        {/* Badge */}
        {badge ? (
          <motion.span
            animate={
              shouldReduceMotion
                ? { opacity: 1 }
                : { opacity: 1, transform: "scale(1)" }
            }
            className={cn(
              "absolute top-3 left-3 rounded-full px-2.5 py-1 font-semibold text-xs shadow-sm",
              badge.toLowerCase() === "sale"
                ? "bg-red-500 text-white"
                : badge.toLowerCase() === "new"
                  ? "bg-emerald-500 text-white"
                  : "bg-primary text-primary-foreground"
            )}
            initial={
              shouldReduceMotion
                ? { opacity: 1 }
                : { opacity: 0, transform: "scale(0.6)" }
            }
            role="status"
            transition={shouldReduceMotion ? { duration: 0 } : SPRING_BOUNCY}
          >
            {badge}
          </motion.span>
        ) : null}

        {/* Wishlist button */}
        <motion.button
          aria-label={
            isWishlisted
              ? `Remove ${title} from wishlist`
              : `Add ${title} to wishlist`
          }
          className={cn(
            "absolute top-3 right-3 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-background/80 backdrop-blur-sm",
            "transition-colors duration-150",
            "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
            isHoverDevice ? "opacity-0 group-hover:opacity-100" : "opacity-100"
          )}
          onClick={handleWishlist}
          type="button"
          whileTap={
            shouldReduceMotion
              ? undefined
              : { scale: 0.85, transition: { duration: 0.1 } }
          }
        >
          <HeartIcon filled={isWishlisted} />
        </motion.button>
      </div>

      {/* Content */}
      <div className="flex flex-1 flex-col gap-2 p-4">
        <h3 className="line-clamp-1 font-semibold text-foreground text-sm tracking-tight">
          {title}
        </h3>

        {rating !== undefined && <RatingStars rating={rating} title={title} />}

        <div className="flex items-baseline gap-2">
          <span className="font-bold text-foreground text-xl tracking-tight">
            {currency}
            {price}
          </span>
          {hasDiscount ? (
            <>
              <span className="text-muted-foreground text-sm line-through">
                {currency}
                {originalPrice}
              </span>
              <span className="rounded-md bg-red-50 px-1.5 py-0.5 font-semibold text-red-600 text-xs dark:bg-red-950/40 dark:text-red-400">
                -{discountPercent}%
              </span>
            </>
          ) : null}
        </div>

        <div className="mt-auto pt-2">
          <SmoothButton
            aria-label={`Add ${title} to cart`}
            className={cn(
              "w-full gap-2",
              isAdded && "bg-emerald-600 text-white hover:bg-emerald-600"
            )}
            disabled={isAdded}
            onClick={handleAddToCart}
            size="default"
            variant="candy"
          >
            <AnimatePresence initial={false} mode="wait">
              {isAdded ? (
                <motion.span
                  animate={{ opacity: 1, transform: "scale(1)" }}
                  className="flex items-center gap-2"
                  exit={{ opacity: 0, transform: "scale(0.8)" }}
                  initial={{ opacity: 0, transform: "scale(0.8)" }}
                  key="added"
                  transition={
                    shouldReduceMotion ? { duration: 0 } : { duration: 0.15 }
                  }
                >
                  <CheckIcon /> Added
                </motion.span>
              ) : (
                <motion.span
                  animate={{ opacity: 1, transform: "scale(1)" }}
                  className="flex items-center gap-2"
                  exit={{ opacity: 0, transform: "scale(0.8)" }}
                  initial={{ opacity: 0, transform: "scale(0.8)" }}
                  key="cart"
                  transition={
                    shouldReduceMotion ? { duration: 0 } : { duration: 0.15 }
                  }
                >
                  <CartIcon /> Add to Cart
                </motion.span>
              )}
            </AnimatePresence>
          </SmoothButton>
        </div>
      </div>
    </motion.div>
  );
}

Similar components