Web InnoventixFreeCode

Product Card

Original · free

A product card with image, price and add-to-cart feedback.

byWeb InnoventixReact + Tailwind
cardproductcards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/card-product.json
card-product.tsx
"use client";

import { useState, useRef, useCallback, useId } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type CartStatus = "idle" | "loading" | "added";

type Product = {
  id: string;
  name: string;
  brand: string;
  price: number;
  compareAt?: number;
  image: string;
  rating: number;
  reviews: number;
  badge?: string;
  swatches: { name: string; hex: string }[];
};

const PRODUCTS: Product[] = [
  {
    id: "sony-wh1000xm5",
    name: "WH-1000XM5 Wireless Headphones",
    brand: "Sony",
    price: 348,
    compareAt: 399,
    image: "/img/gallery/g07.webp",
    rating: 4.8,
    reviews: 2941,
    badge: "Best seller",
    swatches: [
      { name: "Midnight Black", hex: "#1c1c1e" },
      { name: "Platinum Silver", hex: "#d8d8dc" },
      { name: "Smoky Blue", hex: "#5b6b82" },
    ],
  },
  {
    id: "aeron-chair",
    name: "Aeron Ergonomic Chair, Size B",
    brand: "Herman Miller",
    price: 1195,
    image: "/img/gallery/g14.webp",
    rating: 4.9,
    reviews: 812,
    swatches: [
      { name: "Graphite", hex: "#2b2b2b" },
      { name: "Carbon", hex: "#4a4f55" },
      { name: "Mineral", hex: "#c9c4ba" },
    ],
  },
  {
    id: "chemex-6cup",
    name: "Classic 6-Cup Pour-Over Set",
    brand: "Chemex",
    price: 54,
    compareAt: 68,
    image: "/img/gallery/g22.webp",
    rating: 4.7,
    reviews: 1536,
    badge: "Low stock",
    swatches: [
      { name: "Natural Wood", hex: "#b98a52" },
      { name: "Walnut", hex: "#5a3d28" },
    ],
  },
];

function formatUSD(n: number): string {
  return n.toLocaleString("en-US", { style: "currency", currency: "USD" });
}

function StarIcon({ filled }: { filled: number }) {
  const id = useId();
  return (
    <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" aria-hidden="true">
      <defs>
        <linearGradient id={id}>
          <stop offset={`${filled * 100}%`} stopColor="currentColor" />
          <stop offset={`${filled * 100}%`} stopColor="transparent" />
        </linearGradient>
      </defs>
      <path
        d="M10 1.5l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.1l-4.95 2.6.94-5.5-4-3.9 5.53-.8L10 1.5z"
        fill={`url(#${id})`}
        stroke="currentColor"
        strokeWidth="1"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function Rating({ value, reviews }: { value: number; reviews: number }) {
  return (
    <div className="flex items-center gap-1.5">
      <div className="flex items-center text-amber-500 dark:text-amber-400">
        {[0, 1, 2, 3, 4].map((i) => (
          <StarIcon key={i} filled={Math.min(1, Math.max(0, value - i))} />
        ))}
      </div>
      <span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
        {value.toFixed(1)}
        <span className="ml-1 text-zinc-400 dark:text-zinc-500">
          ({reviews.toLocaleString("en-US")})
        </span>
      </span>
    </div>
  );
}

function ProductCard({ product }: { product: Product }) {
  const reduceMotion = useReducedMotion();
  const [status, setStatus] = useState<CartStatus>("idle");
  const [qty, setQty] = useState(1);
  const [liked, setLiked] = useState(false);
  const [swatch, setSwatch] = useState(0);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  const discount = product.compareAt
    ? Math.round((1 - product.price / product.compareAt) * 100)
    : 0;

  const addToCart = useCallback(() => {
    if (status === "loading") return;
    timers.current.forEach(clearTimeout);
    timers.current = [];
    setStatus("loading");
    timers.current.push(
      setTimeout(() => setStatus("added"), 850),
      setTimeout(() => setStatus("idle"), 2600)
    );
  }, [status]);

  return (
    <div className="group relative flex w-full flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm transition-shadow duration-300 hover:shadow-xl hover:shadow-zinc-900/5 focus-within:ring-2 focus-within:ring-indigo-500/40 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:shadow-black/40">
      <div className="relative aspect-[4/3] w-full overflow-hidden bg-zinc-100 dark:bg-zinc-800">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={product.image}
          alt={`${product.brand} ${product.name}`}
          loading="lazy"
          draggable={false}
          className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-105 motion-reduce:transition-none motion-reduce:group-hover:scale-100"
        />

        <div className="absolute left-3 top-3 flex flex-col gap-1.5">
          {product.badge && (
            <span className="inline-flex w-fit items-center rounded-full bg-zinc-900/85 px-2.5 py-1 text-[11px] font-semibold text-white backdrop-blur dark:bg-white/90 dark:text-zinc-900">
              {product.badge}
            </span>
          )}
          {discount > 0 && (
            <span className="inline-flex w-fit items-center rounded-full bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white">
              -{discount}%
            </span>
          )}
        </div>

        <button
          type="button"
          onClick={() => setLiked((v) => !v)}
          aria-pressed={liked}
          aria-label={liked ? "Remove from wishlist" : "Add to wishlist"}
          className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/85 text-zinc-700 shadow-sm backdrop-blur transition-colors hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-900/70 dark:text-zinc-200 dark:hover:bg-zinc-900 dark:focus-visible:ring-offset-zinc-900"
        >
          <svg
            viewBox="0 0 24 24"
            className={
              "h-5 w-5 transition-transform duration-200 " +
              (liked ? "cp-pop scale-110 " : "scale-100 ") +
              (reduceMotion ? "cp-noanim" : "")
            }
            fill={liked ? "currentColor" : "none"}
            stroke="currentColor"
            strokeWidth="2"
            aria-hidden="true"
            style={{ color: liked ? "#f43f5e" : undefined }}
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M12 21s-7.5-4.9-10-9.5C.6 8.4 2.1 5 5.3 5c2 0 3.3 1.2 4.2 2.4L12 10l2.5-2.6C15.4 6.2 16.7 5 18.7 5 21.9 5 23.4 8.4 22 11.5 19.5 16.1 12 21 12 21z"
            />
          </svg>
        </button>
      </div>

      <div className="flex flex-1 flex-col gap-3 p-4">
        <div className="space-y-1">
          <p className="text-[11px] font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
            {product.brand}
          </p>
          <h3 className="text-sm font-semibold leading-snug text-zinc-900 dark:text-zinc-50">
            {product.name}
          </h3>
        </div>

        <Rating value={product.rating} reviews={product.reviews} />

        <div className="flex items-center gap-2">
          {product.swatches.map((s, i) => (
            <button
              key={s.name}
              type="button"
              onClick={() => setSwatch(i)}
              aria-pressed={swatch === i}
              aria-label={`Color: ${s.name}`}
              title={s.name}
              className={
                "h-6 w-6 rounded-full ring-1 ring-inset ring-black/10 transition-transform focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:ring-white/15 dark:focus-visible:ring-offset-zinc-900 " +
                (swatch === i
                  ? "scale-110 ring-2 ring-indigo-500 dark:ring-indigo-400"
                  : "hover:scale-105")
              }
              style={{ backgroundColor: s.hex }}
            />
          ))}
          <span className="ml-1 text-xs text-zinc-500 dark:text-zinc-400">
            {product.swatches[swatch].name}
          </span>
        </div>

        <div className="mt-auto flex items-end justify-between pt-1">
          <div className="flex items-baseline gap-2">
            <span className="text-xl font-bold text-zinc-900 dark:text-zinc-50">
              {formatUSD(product.price)}
            </span>
            {product.compareAt && (
              <span className="text-sm text-zinc-400 line-through dark:text-zinc-500">
                {formatUSD(product.compareAt)}
              </span>
            )}
          </div>

          <div className="inline-flex items-center rounded-lg border border-zinc-200 dark:border-zinc-700">
            <button
              type="button"
              onClick={() => setQty((q) => Math.max(1, q - 1))}
              disabled={qty <= 1}
              aria-label="Decrease quantity"
              className="inline-flex h-8 w-8 items-center justify-center rounded-l-lg text-zinc-600 transition-colors hover:bg-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800"
            >
              <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
                <path strokeLinecap="round" d="M5 12h14" />
              </svg>
            </button>
            <span
              className="w-8 text-center text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100"
              aria-live="polite"
            >
              {qty}
            </span>
            <button
              type="button"
              onClick={() => setQty((q) => Math.min(99, q + 1))}
              disabled={qty >= 99}
              aria-label="Increase quantity"
              className="inline-flex h-8 w-8 items-center justify-center rounded-r-lg text-zinc-600 transition-colors hover:bg-zinc-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800"
            >
              <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
                <path strokeLinecap="round" d="M12 5v14M5 12h14" />
              </svg>
            </button>
          </div>
        </div>

        <button
          type="button"
          onClick={addToCart}
          disabled={status === "loading"}
          aria-live="polite"
          className={
            "relative mt-1 inline-flex h-11 w-full items-center justify-center gap-2 overflow-hidden rounded-xl px-4 text-sm font-semibold text-white shadow-sm transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.98] disabled:cursor-wait dark:focus-visible:ring-offset-zinc-900 motion-reduce:active:scale-100 " +
            (status === "added"
              ? "bg-emerald-600 focus-visible:ring-emerald-500 hover:bg-emerald-600"
              : "bg-indigo-600 hover:bg-indigo-700 focus-visible:ring-indigo-500")
          }
        >
          <AnimatePresence mode="wait" initial={false}>
            {status === "idle" && (
              <motion.span
                key="idle"
                initial={reduceMotion ? false : { y: 12, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={reduceMotion ? undefined : { y: -12, opacity: 0 }}
                transition={{ duration: 0.18 }}
                className="inline-flex items-center gap-2"
              >
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l3-8H6.4M7 13L5.4 5M7 13l-2 4h12M9 21a1 1 0 100-2 1 1 0 000 2zm8 0a1 1 0 100-2 1 1 0 000 2z" />
                </svg>
                Add to cart · {formatUSD(product.price * qty)}
              </motion.span>
            )}
            {status === "loading" && (
              <motion.span
                key="loading"
                initial={reduceMotion ? false : { opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={reduceMotion ? undefined : { opacity: 0 }}
                transition={{ duration: 0.15 }}
                className="inline-flex items-center gap-2"
              >
                <svg viewBox="0 0 24 24" className={"h-4 w-4 " + (reduceMotion ? "" : "cp-spin")} fill="none" aria-hidden="true">
                  <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.3" strokeWidth="3" />
                  <path d="M21 12a9 9 0 00-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
                </svg>
                Adding…
              </motion.span>
            )}
            {status === "added" && (
              <motion.span
                key="added"
                initial={reduceMotion ? false : { y: 12, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={reduceMotion ? undefined : { y: -12, opacity: 0 }}
                transition={{ duration: 0.18 }}
                className="inline-flex items-center gap-2"
              >
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M20 6L9 17l-5-5" />
                </svg>
                Added {qty} to cart
              </motion.span>
            )}
          </AnimatePresence>
        </button>
      </div>
    </div>
  );
}

export default function CardProduct() {
  return (
    <section className="relative w-full bg-zinc-50 px-4 py-16 sm:px-6 lg:py-24 dark:bg-zinc-950">
      <style>{`
        @keyframes cp-spin { to { transform: rotate(360deg); } }
        .cp-spin { animation: cp-spin 0.7s linear infinite; transform-origin: center; }
        @keyframes cp-pop { 0% { transform: scale(1); } 45% { transform: scale(1.35); } 100% { transform: scale(1.1); } }
        .cp-pop { animation: cp-pop 0.32s ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .cp-spin, .cp-pop { animation: none !important; }
          .cp-noanim { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <header className="mx-auto mb-12 max-w-2xl text-center">
          <p className="text-sm font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
            Featured
          </p>
          <h2 className="mt-2 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
            Shop the essentials
          </h2>
          <p className="mt-3 text-zinc-600 dark:text-zinc-400">
            Pick a color, set the quantity, add to cart. Every control is real and keyboard-accessible.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {PRODUCTS.map((p) => (
            <ProductCard key={p.id} product={p} />
          ))}
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →