Product Card
Original · freeA product card with image, price and add-to-cart feedback.
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"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
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 quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

Blog Card
OriginalA blog post card with cover image, tag and meta row.

