Product card
A shopping agent's result: photo, price and sale price in the right currency, a rating you read as a shape, color swatches and sizes that are real radio groups, stock, delivery, compare and an Add to cart that shows progress and confirms.
On this page
Installation
Usage
import { ProductCard } from "@/components/ui/chatcn/product-card"
<ProductCard
title={product.title}
merchant={product.store}
image={product.image}
price={product.price}
salePrice={product.salePrice}
currency="EUR"
variants={[{ name: "Size", options: sizes.map((s) => ({ value: s, label: `EU ${s}` })) }]}
stock="in-stock"
onAddToCart={(selection) => cart.add(product.id, selection)}
/>Every widget takes plain props from a tool result, so map the tool output straight onto it. While the call runs, pass loading for a skeleton that matches the final layout; when it fails, pass error (and onRetry). Install with product-card and nothing else from the kit is required at runtime.
Until every variant group has a choice, the button says what is missing (Select size) and stays disabled. Groups with one available option are chosen for you. When onAddToCart returns a promise, the label crossfades to Adding with a spinner, then to Added to cart; the button never changes width.
Examples
List rows
States
Props
| Prop | Type | Notes |
|---|---|---|
title, merchant, href | string | Without a title the card shows the empty state |
image, imageAlt | string | Holds a 4:3 frame; a broken image shows a quiet glyph |
badge | string | Best match, Lowest price |
price, salePrice | number | Major units. A lower sale price strikes the regular one |
currency, locale | string | Intl.NumberFormat currency formatting |
rating, reviewCount | number | Out of 5. Large counts go compact: 12.9K |
variants | { name, options: { value, label, swatch?, available? }[] }[] | A swatch color makes a swatch picker |
selection, defaultSelection, onSelectionChange | Record<string, string> | Keyed by group name |
stock, stockCount | "in-stock" | "low-stock" | "out-of-stock" | "preorder" | Green, amber, gray or blue, each with an icon and words |
shipping | string | Free delivery Thu, Oct 29 |
onAddToCart, cartState | "idle" | "adding" | "added" | Uncontrolled from the promise, or controlled |
comparable, compared, defaultCompared, onComparedChange | boolean | Compare checkbox |
layout | "grid" | "list" | Default grid |
loading, error, onRetry, empty | States | |
labels | Partial<ProductCardLabels> | Every string the card shows or announces, for translation. Counts and names arrive as function arguments, so the word order is yours |
Keyboard
| Key | Action |
|---|---|
Tab | Title link, each variant group, compare, Add to cart |
ArrowLeft, ArrowRight | Move through the options in a variant group |
Space | Toggle compare |
Enter | Add to cart |
Accessibility
- Variant pickers are native radio groups inside a fieldset, so the group name and each option's name (Ember red, EU 42, sold out) are read without extra work. Swatches never rely on color alone.
- Prices read as Sale price and Regular price. The stars are hidden; the rating and review count are read as text.
- Adding and Added to cart are announced through a polite status region.
Motion
- The cart label crossfades with a 2 px blur in 150 ms. Under reduced motion it swaps instantly.
- Photos fade in over 200 ms once loaded. Cards never lift or scale on hover.