Product Card
Product Card é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
cards
O que é
Product Card é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i motion
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Product Card
"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>
);
}