Product Card with Price, Rating, Colour Swatches and Add to Cart

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.

Code

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

When to use it

How to customize it

Accessibility

Questions

How do the swatches change the picture?

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.

Why are the colour classes written out in full?

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.

How do I connect the button to a real cart?

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.

Can I show sizes as well as colours?

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.

How do I show a half-star rating?

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.

More in Ecommerce

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.

Product

Who It's For

Features

Resources

Company

View Sitemap