Display

Product Card

A storefront product card — one stretched link, variants that swap the photo, a discount derived from the was-price, a sold-out veil and a four-phase Add to cart that never fakes success.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Heart, ImageOff, LoaderCircle, ShoppingCart, Star } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long the "Added" confirmation holds before the button falls back to idle. */
const SUCCESS_RESET_MS = 2000

const BADGE_TONE = {
  /** "New", "Bestseller" — readable over any photo thanks to the translucent surface. */
  neutral: "border bg-background/90 text-foreground backdrop-blur-sm",
  /** "Sale", "-30%" — the one badge allowed to shout. */
  sale: "bg-primary text-primary-foreground",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/product-card.json

Prompt

Build a React + TypeScript + Tailwind "ProductCard" component (lucide-react:
Check, Heart, ImageOff, LoaderCircle, ShoppingCart, Star). No other runtime deps.

Contract
- export const ProductCard = forwardRef<HTMLElement, ProductCardProps>, plus the
  types ProductPrice / ProductBadge / ProductVariant. Props extend
  Omit<React.HTMLAttributes<HTMLElement>, "onChange">; the rest spreads onto the
  <article> root and className merges through cn().
- Required: title: string; href: string; price: { amount: number; currency:
  string }. `amount` is in MAJOR units (12.5 means twelve fifty) and `currency`
  is an ISO 4217 code — the component formats, it never parses money strings.
- Optional: imageUrl; imageAlt (default ""); hoverImageUrl; compareAtPrice
  (number, same currency); rating: { value: number; count: number }; badges:
  { label: string; tone?: "neutral" | "sale" | "warning" }[]; variants:
  { id, label, swatch?, imageUrl? }[]; selectedVariantId + onVariantChange;
  onAddToCart?: (variantId?: string) => void | Promise<unknown>; inStock
  (default true); wishlisted + onWishlistToggle; layout: "grid" | "list"
  (default "grid"); locale (default "en-US"); maxVisibleVariants (default 5).
- Two controlled/uncontrolled pairs, same rule for both: the prop wins when
  present, internal state is the fallback. selectedVariantId ?? internal pick ??
  variants[0]; wishlisted ?? internal boolean.
- Optional blocks are driven by data, not by boolean flags: no `rating` means no
  rating row, no `variants` means no swatch row, no `onAddToCart` means no cart
  button at all, and the wishlist heart appears only when `onWishlistToggle` or
  `wishlisted` is passed. Never render an inert control.

Behavior
- The whole card is clickable through exactly ONE anchor: the title link carries
  an ::after pseudo-element with inset-0, and the <article> root is `relative`.
  Do NOT wrap the card in an <a> and do NOT put buttons inside an anchor — that
  is invalid HTML and screen readers announce one unusable blob. Every real
  control (swatches, wishlist, cart) gets `relative z-10` so it sits above the
  stretched overlay and stays clickable. The line-clamp lives on an inner <span>
  inside the anchor, because overflow:hidden on the anchor itself would clip the
  ::after back to the title box.
- Discount is DERIVED, never passed in: discounted = compareAtPrice !== undefined
  && compareAtPrice > price.amount; off = round((1 - amount / compareAtPrice) *
  100), and the badge is suppressed when off rounds below 1%. A percentage sent
  from the backend goes stale next to a fresh price and turns into a support
  ticket.
- Money: one memoised Intl.NumberFormat(locale, { style: "currency", currency })
  and one plain Intl.NumberFormat(locale) for the review count. The locale is
  ALWAYS explicit (default "en-US") — Intl.*(undefined) makes the server and the
  browser disagree and hydration breaks. Decimal digits come from the currency
  code, so JPY prints no decimals and USD prints two, with no /100 anywhere.
- Add to cart is a four-phase machine: idle → pending → success (holds 2s, then
  back to idle) | error. The handler's return value is wrapped in `new
  Promise(resolve => resolve(onAddToCart?.(id)))`, NOT `Promise.resolve(fn())`:
  the executor form turns a SYNCHRONOUS throw into a rejection too, instead of
  letting it escape the click handler and leave the button spinning forever. A
  rejection shows "Try again" and stays clickable — never an optimistic
  checkmark. A synchronous handler still settles on the next microtask, so no
  invented latency. The button is disabled while pending and when out of stock,
  and a polite sr-only live region announces success and failure.
- Variants are a radiogroup: role="radiogroup" wrapping role="radio" buttons with
  NOTHING role-less in between (a bare div there makes the group read as empty),
  aria-checked on each, and a roving tabindex — one tab stop for the group,
  arrow keys (both axes) move the selection and move focus with it. Selecting a
  variant that carries `imageUrl` swaps the main photo, and that selection also
  rides along as onAddToCart's argument, so the swatch is never decorative.
- maxVisibleVariants is clamped to at least 1 (a 0 would swallow the whole row).
  When the selected variant sits past the cap it is swapped into the last
  visible slot, otherwise the group would render with nothing selected. The
  leftover "+N" counter lives OUTSIDE the radiogroup — it is a count, not an
  option — and carries an sr-only sentence.
- hoverImageUrl is mounted from the first paint at opacity-0 rather than injected
  on first hover, so the browser has already fetched and decoded it and the swap
  is instant instead of a blank flash. It yields when the active variant supplies
  its own image, so a red variant never cross-fades into the blue back view.
- inStock: false draws a translucent "Sold out" veil over the photo and disables
  the cart button, but the veil is pointer-events-none — a sold-out product still
  has a product page worth opening.
- Missing imageUrl renders a muted placeholder tile inside the same aspect box:
  no broken-image icon, no layout shift.
- Lifecycle: the success timer is cleared on unmount, and `mountedRef` is set to
  true INSIDE the mount effect (not merely cleared in cleanup), or StrictMode's
  mount → cleanup → mount leaves it false forever and pending never resolves.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch in any className: bg-card +
  text-card-foreground (root), border, bg-muted (image well and placeholder),
  text-muted-foreground (secondary text), bg-primary + text-primary-foreground
  (cart button, sale badge), bg-primary/10 + text-primary (discount chip),
  fill-primary + text-primary (star, filled heart), bg-destructive +
  text-background (warning badge), border-destructive/40 + text-destructive
  (error state), bg-background/90 (neutral badge, wishlist button, sold-out
  chip), ring-ring + ring-offset-card for focus-visible.
- The ONE deliberate exception is `variant.swatch`, applied through the style
  attribute: a physical product's colour is DATA (it can be a hex, an oklch, even
  a gradient from the PIM) and cannot be derived from a theme token. Everything
  else stays token-driven so the card inherits any palette and dark mode for
  free. A variant with no swatch renders as a text chip instead.
- Badge tones differ by FILL WEIGHT, not hue (translucent surface / solid primary
  / solid destructive), so they stay distinguishable in a monochrome palette.
- Accessibility: the rating is one role="img" span with an aria-label spelling out
  "Rated 4.6 out of 5 from 1,284 reviews" while the glyphs stay aria-hidden; the
  price figures are aria-hidden and followed by an sr-only sentence that states
  which number is actually charged ("Sale price $90.30, down from $129, 30
  percent off"), because hearing "90.30 129" leaves the listener guessing.
  The wishlist button uses aria-pressed and a label naming the product.
- Motion: photo zoom and cross-fade only, both on transition-[opacity,scale] —
  in Tailwind v4 `scale-105` writes the CSS `scale` property, so transitioning
  `transform` would silently do nothing. Everything carries
  motion-reduce:transition-none / motion-reduce:animate-none, and no state
  depends on an animation finishing.
- layout="grid" is a 4:5 photo above the copy; layout="list" turns the photo into
  a fixed-width rail (w-28 / sm:w-40) with the copy centred beside it and the
  cart button shrunk to self-start. Same JSX tree, different class slots.

Customization levers
- Photo shape: aspect-[4/5] is the one aspect ratio in the file — swap it for
  aspect-square (grocery) or aspect-[3/4] (fashion). layout="list" ignores it.
- Density: p-4 + gap-2 reads as a comfortable retail grid; p-3 + gap-1.5 +
  text-xs gives a dense catalogue. Add a third layout the same way the existing
  two work — extra class slots, not a second render path.
- Block set is data-driven: omit rating / variants / badges / onAddToCart /
  onWishlistToggle and those parts do not exist. To add a shipping line or a
  seller name, drop one more element into the copy column; nothing else reads it.
- Badge vocabulary: BADGE_TONE is a plain Record of three token strings. Add
  "info" or "eco" by extending that map and the ProductBadge tone union.
- Cart affordance: change the confirmation hold via SUCCESS_RESET_MS, swap the
  full-width button for an icon-only button that appears on hover, or replace it
  with a quantity stepper — the four-phase machine is independent of the trigger.
  Keep it a real <button>; do not turn it into an anchor.
- Currency and locale: `currency` decides the symbol AND the decimal count,
  `locale` decides grouping and symbol placement. If your backend sends minor
  units (integer cents), divide in the data layer, not here.
- Wishlist: pass `wishlisted` + `onWishlistToggle` for a server-backed list, pass
  only the callback for a local one, pass neither to remove the heart.
- Variant density: maxVisibleVariants trades swatch row height for a "+N" link to
  the product page; set it high for a two-colour product, low for 40 sizes.

Concepts

  • Stretched link, single anchor — the whole card is clickable because the title <a> grows an ::after with inset-0 over the relative root. The tempting alternative — wrapping the card in an anchor — is invalid HTML the moment a <button> goes inside it, and screen readers collapse it into one unusable target. Here there is exactly one link and the buttons stay separate elements.
  • Lifted controls — every real control gets relative z-10 so it paints and receives clicks above that overlay. Forget it on one swatch and the swatch silently becomes "open the product page". The +N counter deliberately stays below the overlay, because it is not an option — clicking there should open the page.
  • Derived discount — the card is given a was-price, never a percentage. round((1 - amount / compareAtPrice) * 100) is computed at render, so a repriced product can never display a stale "−30%" beside a fresh number. Below 1% the chip disappears rather than printing "−0%".
  • No optimistic successonAddToCart runs inside a new Promise executor, so a rejection and a synchronous throw both land on "Try again"; the checkmark only appears after the handler actually settled, and the button can never be stranded mid-spin. The 2-second hold then returns it to idle so a second unit can be added.
  • Variant selection with a downstream effect — a swatch that only looks selected is decoration. Here the choice swaps the main photo, suppresses the hover cross-fade (so a red variant never fades into the blue back view), and is handed to onAddToCart as the argument, so the host adds the unit the shopper was actually looking at.
  • Currency-owned decimals — one memoised Intl.NumberFormat with an explicit locale decides symbol, grouping and digit count from the ISO code alone: JPY prints no decimals, USD prints two. An implicit locale would make the server and the browser format differently and break hydration.

On This Page