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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/product-card.jsonPrompt
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::afterwithinset-0over therelativeroot. 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-10so it paints and receives clicks above that overlay. Forget it on one swatch and the swatch silently becomes "open the product page". The+Ncounter 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 success —
onAddToCartruns inside anew Promiseexecutor, 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
onAddToCartas the argument, so the host adds the unit the shopper was actually looking at. - Currency-owned decimals — one memoised
Intl.NumberFormatwith 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.