A single product card for a shop. The top half shows the product on a tinted stage with a saving badge and a wishlist button. Below are the brand, name, a one-line detail, the price with the old price struck through, a star rating with a review count, four colour swatches and a full-width add-to-cart button. Picking a swatch recolours the product drawing and the stage, and the button switches to a confirmed state when pressed. The card is 384 pixels wide at most and shrinks to fit a phone.
import { useState } from "react";
const product = {
brand: "Fellmark",
name: "Ridgeline Insulated Bottle",
detail: "750 ml, keeps drinks cold for 24 hours",
price: 34,
was: 42,
rating: 4.7,
reviews: 128,
};
const colours = [
{ name: "Glacier", swatch: "bg-sky-500", body: "fill-sky-500", shade: "fill-sky-700", stage: "bg-sky-50" },
{ name: "Moss", swatch: "bg-emerald-600", body: "fill-emerald-600", shade: "fill-emerald-800", stage: "bg-emerald-50" },
{ name: "Ember", swatch: "bg-orange-500", body: "fill-orange-500", shade: "fill-orange-700", stage: "bg-orange-50" },
{ name: "Graphite", swatch: "bg-slate-700", body: "fill-slate-700", shade: "fill-slate-900", stage: "bg-slate-100" },
];
const starPath = "M10 1.6l2.47 5.3 5.8.72-4.28 3.99 1.12 5.74L10 14.5l-5.11 2.85 1.12-5.74L1.73 7.62l5.8-.72L10 1.6z";
export default function ProductCardWithSwatchesAndAddToCart() {
const [colour, setColour] = useState(0);
const [saved, setSaved] = useState(false);
const [added, setAdded] = useState(false);
const c = colours[colour];
const fullStars = Math.round(product.rating);
return (
<div className="flex justify-center bg-slate-100 px-4 py-12">
<article className="w-full max-w-sm overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-slate-200">
<div className={`relative flex h-72 items-end justify-center transition-colors duration-300 ${c.stage}`}>
<span className="absolute left-4 top-4 rounded-full bg-slate-900 px-3 py-1 text-xs font-semibold text-white">Save ${product.was - product.price}</span>
<button
type="button"
aria-label="Save to wishlist"
aria-pressed={saved}
onClick={() => setSaved(!saved)}
className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white text-slate-700 shadow-sm transition hover:text-rose-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2"
>
<svg viewBox="0 0 20 20" fill={saved ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" aria-hidden="true" className={`h-5 w-5 ${saved ? "text-rose-600" : ""}`}>
<path d="M10 16.5s-6-3.6-6-8.1A3.4 3.4 0 0 1 10 6.3a3.4 3.4 0 0 1 6 2.1c0 4.5-6 8.1-6 8.1z" />
</svg>
</button>
<svg viewBox="0 0 120 240" aria-hidden="true" className="h-64 w-32">
<ellipse cx="60" cy="230" rx="38" ry="6" className="fill-slate-900/10" />
<path d="M48 8a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v6H48z" className="fill-slate-400" />
<rect x="40" y="14" width="40" height="26" rx="5" className="fill-slate-800" />
<rect x="40" y="22" width="40" height="3" className="fill-slate-600" />
<rect x="40" y="30" width="40" height="3" className="fill-slate-600" />
<path d="M44 40h32v8c0 6 12 10 12 24v140a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V72c0-14 12-18 12-24z" className={`transition-colors duration-300 ${c.body}`} />
<path d="M76 48c0 6 12 10 12 24v140a16 16 0 0 1-16 16h-5a11 16 0 0 0 11-16V72c0-11-7-17-9-24z" className={`transition-colors duration-300 ${c.shade}`} />
<rect x="40" y="76" width="6" height="110" rx="3" className="fill-white/40" />
<path d="M52 132l8-14 6 9 4-5 8 10z" className="fill-white/90" />
<rect x="50" y="138" width="30" height="3" rx="1.5" className="fill-white/90" />
</svg>
</div>
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">{product.brand}</p>
<h3 className="mt-1 text-lg font-semibold leading-snug text-slate-900">{product.name}</h3>
</div>
<p className="flex-none text-right">
<span className="block text-xl font-semibold tracking-tight text-slate-900">${product.price}</span>
<span className="block text-sm text-slate-500 line-through"><span className="sr-only">Was </span>${product.was}</span>
</p>
</div>
<p className="mt-1 text-sm text-slate-600">{product.detail}</p>
<div className="mt-3 flex items-center gap-2 text-sm">
<span className="flex gap-0.5" role="img" aria-label={`Rated ${product.rating} out of 5`}>
{[1, 2, 3, 4, 5].map((n) => (
<svg key={n} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className={`h-4 w-4 ${n <= fullStars ? "text-amber-500" : "text-slate-200"}`}><path d={starPath} /></svg>
))}
</span>
<span className="font-semibold text-slate-900">{product.rating}</span>
<a href="#" className="text-slate-600 underline decoration-slate-300 underline-offset-4 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2">{product.reviews} reviews</a>
</div>
<div className="mt-5 flex items-center justify-between gap-4 border-t border-slate-100 pt-5">
<p className="text-sm text-slate-600">Colour: <span className="font-semibold text-slate-900">{c.name}</span></p>
<div role="radiogroup" aria-label="Colour" className="flex gap-1">
{colours.map((o, i) => (
<button
key={o.name}
type="button"
role="radio"
aria-checked={colour === i}
aria-label={o.name}
onClick={() => { setColour(i); setAdded(false); }}
className="flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
>
<span className={`block h-7 w-7 rounded-full ring-offset-2 transition ${o.swatch} ${colour === i ? "ring-2 ring-slate-900" : "ring-1 ring-black/10"}`} />
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => setAdded(!added)}
className={`mt-5 flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-semibold text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 ${added ? "bg-emerald-700 hover:bg-emerald-800" : "bg-slate-900 hover:bg-slate-700"}`}
>
{added ? (
<>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5"><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>
Added to cart
</>
) : (
<>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5"><path d="M2.5 3.5h2l2 9h8l1.5-6.5H5.6" /><circle cx="7.5" cy="16" r="1" /><circle cx="13.5" cy="16" r="1" /></svg>
Add to cart
</>
)}
</button>
<p className="mt-3 text-center text-xs text-slate-500">Free delivery over $50. Returns within 30 days.</p>
</div>
</article>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/product-card-with-swatches-and-add-to-cart.json
The selected position is kept in `useState`. The drawing and the stage read their colour classes from the matching entry in `colours`, so a click swaps the classes and a short transition fades between them.
Tailwind only generates classes it can find as complete strings in your files. Building a class such as `bg-` plus a colour name at run time would leave it out of the stylesheet, so each option lists its full class names.
Replace the `setAdded` call in the button's `onClick` with your cart function, and set `added` to true once it succeeds. Pass the product and `colours[colour].name` so the right variant is added.
Yes. Copy the swatch row, give it its own state and label, and render text pills such as S, M and L in place of the coloured dots.
The card rounds to whole stars and prints the exact number beside them. For half stars, overlay a filled star clipped to half width on an empty one.
Related: Blog Post Card Grid with Category Tags and Reading Time, Testimonial Cards with Star Ratings and Initials Avatars, Button Set: Primary, Secondary, Ghost, Icon and Loading, Pricing Table with Monthly and Yearly Toggle.
More ecommerce or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.