ProductCard
A product in a grid, a carousel, a list or search results: its photo, name, price and, optionally, rating, colours, a flag and an add action, with the whole card linking to the product page.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
ProductCard is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { ProductCard } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
A product in a grid, a carousel, a list or search results: its photo, name, price and, optionally, rating, colours, a flag and an add action, with the whole card linking to the product page.
Use it when
- Listing products to browse: a category grid, "You may also like", search results, a wishlist.
- The card should link to the product page and still carry its own add button.
- A list needs the photo beside the text:
layout="horizontal".
Don't use it when
- It is a line in the cart or an order: that has a quantity and a remove action, not a card-sized photo. Compose it from
PriceandQuantityStepperin a row. - The content isn't a product for sale. Use
Card. - You are on the product page itself. Use
ProductGallery,Price,RatingandVariantPickerthere.
Example
<ProductCard
name="Stoneware mug"
href="/products/stoneware-mug"
image={{ src: "/img/mug-fern.jpg", alt: "" }}
subtitle="Fern glaze"
price={24}
compareAt={30}
badge="-20%"
rating={{ value: 4.5, count: 128 }}
swatches={[{ name: "Fern", color: "#5b7a6a" }, { name: "Chalk", color: "#ffffff" }]}
onQuickAdd={() => addToCart("stoneware-mug")}
/>
<ProductCard
layout="horizontal"
name="Soy candle"
href="/products/soy-candle"
image={{ src: "/img/candle.jpg", alt: "" }}
price={18}
soldOut
action={<Button variant="secondary" size="sm">Add to cart</Button>}
/>
Variants
| Prop | What it is for |
|---|---|
layout="vertical" | Default. The photo above the text, for grids and carousels. The action sits at the foot, so actions line up across a row. |
layout="horizontal" | The photo beside the text, a third of the width up to 2.5 × --dt-size-avatar-xl (160px), for lists and search results. |
ratio | "1:1" by default, which suits any catalogue. "4:5" for apparel and people; "3:4", "4:3" or a number for other photography. Use one ratio across a grid. |
compareAt | A sale. Price strikes through the old price and announces "Was …, now …". |
badge | A flag over the top corner of the photo: "New", "-20%". A string becomes a solid neutral Badge; pass your own Badge for another tone. |
swatches | Colour dots, display only. Past maxSwatches (5) the rest collapse into "+3". |
soldOut | Washes out the photo, shows soldOutLabel ("Sold out") in place of the badge, disables the quick-add button, and clones action with disabled: true. |
onQuickAdd | A round add button over the photo's bottom corner, named "Add {name} to cart". For dense grids. |
action | Anything at the foot of the card, usually an "Add to cart" Button. For roomier cards. |
Composition
- Built on
Card: its surface, border, radius and thehrefpattern. The name is the one real link; anaria-hiddencopy of it is stretched over the card for a pointer, and the action and quick-add button sit above it so they stay separately clickable and focusable. The card's padding is the tighter--dt-space-inset-sm. - The photo is an
Image(so anAspectRatio): the frame reserves its space before the photo loads, and withoutimageit shows the system's placeholder. - The price is a
Price, the rating a small displayRating, the flag aBadge, the quick add anIconButton. - Put cards in a
Grid, or a CSS grid ofrepeat(auto-fill, minmax(…, 1fr)). The card fills its cell's height. Passas="li"inside a list. - Don't put other links or controls in the text: only
actionand the quick-add button sit above the stretched link.
Tokens
Tier 3, in tokens/component/commerce.css, each repeated under .dark:
--dt-product-subtitle-color(--dt-text-secondary): the subtitle and the "+3".--dt-product-soldout-overlay(--dt-surface-glass): the wash over a sold-out photo. The system has no opacity token; the translucent glass surface dims the photo in either mode.--dt-swatch-border(--dt-border-strong): the hairline around each colour dot, so a white one shows on a white card.
It also reads Card's --dt-card-* tokens, --dt-radius-media for the photo, --dt-text-label-lg-* for the name and --dt-text-body-sm-* for the subtitle, and the tokens of Price, Rating, Badge and IconButton. Swatch colours come from product data: they are content, not tokens.
Accessibility
- With
href, the name is the card's only link, so a screen reader lists each product once, by its name. The stretched copy isaria-hiddenand out of the tab order. - The text comes first in the source and the photo is moved above it visually, so a screen reader and the Tab key meet the name (the link) first, then the flag and the quick-add button.
- The quick-add button is a real button named "Add {name} to cart" (override with
quickAddLabel), so a list of them is not a column of identical "Add" buttons. - The swatches are one
role="img"named "Colours: Fern, Harbour, …", listing every colour, the collapsed ones included. Each dot has a tooltip. - The sale is announced by
Price; the rating is named "4.5 out of 5 stars, 128 reviews" byRating. - "Sold out" is text, not only the wash. The disabled quick-add button leaves the tab order.
- Alt text describes the photo. The name is already read, so pass
alt: ""when the photo would only repeat it.
Content
nameis the product's name as the catalogue has it, sentence case. It clamps to two lines.subtitleis one short line: the brand, the variant ("Fern glaze") or a summary ("8 colours").badgeis one or two words or a number: "New", "-20%", "Sale". Don't put the price in it.- Action labels start with a verb: "Add to cart", "Add to bag".
Props
The typed contract, from ProductCard.d.ts.
import * as React from "react";
/** A product image, with the alt text the page needs. */
export interface ProductCardImage {
/** URL of the image. */
src: string;
/** What the image shows, e.g. "Green ceramic mug, side view". Pass "" when it would only repeat the name. */
alt: string;
}
/** One colour a product comes in, shown as a dot. */
export interface ProductCardSwatch {
/** The colour's name, read to a screen reader and shown as a tooltip: "Sand", "Forest green". */
name: string;
/** Any CSS colour from the product data, e.g. "#c8b79a". It is content, not a design token. */
color: string;
}
/** A product in a grid, a carousel, a list or search results: picture, name, price and an optional action. */
export interface ProductCardProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "title"> {
/** The product's name. It is the card's heading text and, with href, its one link. */
name: string;
/**
* The product page. The name becomes the one link a screen reader hears, and a copy
* stretches over the whole card for a pointer, as Card's href does. The action and the
* quick-add button stay separately focusable and clickable above it.
*/
href?: string;
/** The product photo. Without one the frame keeps its space and shows the Image placeholder. */
image?: ProductCardImage;
/** Shape of the photo frame. "4:5" suits apparel; a number is width / height. @default "1:1" */
ratio?: "1:1" | "4:5" | "3:4" | "4:3" | number;
/** The price in major units, passed to Price as amount. */
price: number;
/** The original price. When greater than price, the card shows a sale. */
compareAt?: number;
/** ISO 4217 currency code, passed to Price. @default "USD" */
currency?: string;
/** BCP 47 locale for the price and the review count. Set it when server rendering. */
locale?: string;
/** The average rating and, optionally, how many reviews it comes from. Shown as a small Rating. */
rating?: { value: number; count?: number };
/**
* A flag over the top corner of the photo: "New", "-20%". A string becomes a solid neutral
* Badge; pass a Badge of your own for another tone. Hidden while soldOut, whose label takes
* its place.
*/
badge?: React.ReactNode;
/** A line under the name: the brand, or a summary of the variants ("3 sizes"). */
subtitle?: string;
/** Colours the product comes in, as small dots. Display only; choose a colour with VariantPicker. */
swatches?: ProductCardSwatch[];
/** How many swatches show before the rest collapse into "+3". @default 5 */
maxSwatches?: number;
/** Washes out the photo, shows soldOutLabel over it, and disables the quick-add button and the action. */
soldOut?: boolean;
/** The sold-out flag's text. @default "Sold out" */
soldOutLabel?: string;
/**
* An action at the foot of the card, e.g. a Button that adds to the cart. It sits above the
* stretched link. While soldOut it is cloned with disabled: true.
*/
action?: React.ReactNode;
/** Renders a compact add button over the photo's bottom corner and calls this when it is pressed. */
onQuickAdd?: () => void;
/** Accessible name of the quick-add button. @default `Add ${name} to cart` */
quickAddLabel?: string;
/** vertical stacks the photo over the text, for grids; horizontal puts it beside, for lists and search results. @default "vertical" */
layout?: "vertical" | "horizontal";
/** The element the card renders as, passed to Card: "article" or "li" in a list of products. @default "div" */
as?: React.ElementType;
}
export declare function ProductCard(props: ProductCardProps): React.JSX.Element;
Tokens it reads
Every custom property ProductCard.jsx resolves, read from the source: 6 component, 164 semantic. Component tier first, because that is the one to reach for.
- component Its own tier. Override one of these and only this component moves.
- semantic Shared roles. Override one and everything using that role moves with it.
| Token | Tier | Declared as |
|---|---|---|
--dt-card-elevation | component | var(--dt-elevation-0) |
--dt-card-elevation-hover | component | var(--dt-elevation-1) |
--dt-card-fg | component | var(--dt-text-primary) |
--dt-product-soldout-overlay | component | var(--dt-surface-glass) |
--dt-product-subtitle-color | component | var(--dt-text-secondary) |
--dt-swatch-border | component | var(--dt-border-strong) |
--dt-border-width-default | semantic | var(--dt-dim-hair) |
--dt-elevation-2 | semantic | var(--dt-shadow-raw-2) |
--dt-radius-media | semantic | var(--dt-radius-raw-12) |
--dt-radius-pill | semantic | var(--dt-radius-raw-full) |
--dt-size-avatar-xl | semantic | var(--dt-dim-16) |
--dt-size-icon-md | semantic | var(--dt-dim-5) |
--dt-size-icon-sm | semantic | var(--dt-dim-4) |
--dt-space-inline-2xs | semantic | var(--dt-dim-1) |
--dt-space-inline-md | semantic | var(--dt-dim-4) |
--dt-space-inset-sm | semantic | var(--dt-dim-3) |
--dt-space-inset-xs | semantic | var(--dt-dim-2) |
--dt-space-stack-2xs | semantic | var(--dt-dim-1) |
--dt-space-stack-sm | semantic | var(--dt-dim-3) |
--dt-space-stack-xs | semantic | var(--dt-dim-2) |
--dt-text-artboard-body-line | semantic | calc(var(--dt-line-height-md) * 2.35) |
--dt-text-artboard-body-size | semantic | calc(var(--dt-font-size-md) * 2.5) |
--dt-text-artboard-display-family | semantic | var(--dt-text-display-lg-family) |
--dt-text-artboard-display-line | semantic | calc(var(--dt-line-height-7xl) * 2.55) |
--dt-text-artboard-display-size | semantic | calc(var(--dt-font-size-7xl) * 2.8) |
--dt-text-artboard-display-tracking | semantic | var(--dt-tracking-tightest) |
--dt-text-artboard-display-weight | semantic | var(--dt-text-display-lg-weight) |
--dt-text-artboard-meta-size | semantic | calc(var(--dt-font-size-sm) * 2.2) |
--dt-text-artboard-meta-tracking | semantic | var(--dt-tracking-wider) |
--dt-text-artboard-title-line | semantic | calc(var(--dt-line-height-7xl) * 1.7) |
--dt-text-artboard-title-size | semantic | calc(var(--dt-font-size-7xl) * 1.75) |
--dt-text-body-lg-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-lg-line | semantic | var(--dt-line-height-lg) |
--dt-text-body-lg-size | semantic | var(--dt-font-size-lg) |
--dt-text-body-lg-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-body-lg-weight | semantic | var(--dt-font-weight-regular) |
--dt-text-body-md-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-md-line | semantic | var(--dt-line-height-md) |
--dt-text-body-md-size | semantic | var(--dt-font-size-md) |
--dt-text-body-md-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-body-md-weight | semantic | var(--dt-font-weight-regular) |
--dt-text-body-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-sm-line | semantic | var(--dt-line-height-sm) |
--dt-text-body-sm-size | semantic | var(--dt-font-size-sm) |
--dt-text-body-sm-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-body-sm-weight | semantic | var(--dt-font-weight-regular) |
--dt-text-body-xs-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-xs-line | semantic | var(--dt-line-height-xs) |
--dt-text-body-xs-size | semantic | var(--dt-font-size-xs) |
--dt-text-body-xs-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-body-xs-weight | semantic | var(--dt-font-weight-regular) |
--dt-text-brand | semantic | var(--dt-color-primary-700) |
--dt-text-brand-secondary | semantic | var(--dt-color-secondary-700) |
--dt-text-code-md-family | semantic | var(--dt-font-family-mono) |
--dt-text-code-md-line | semantic | var(--dt-line-height-sm) |
--dt-text-code-md-size | semantic | var(--dt-font-size-sm) |
--dt-text-code-md-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-code-md-weight | semantic | var(--dt-font-weight-regular) |
--dt-text-code-sm-family | semantic | var(--dt-font-family-mono) |
--dt-text-code-sm-line | semantic | var(--dt-line-height-xs) |
--dt-text-code-sm-size | semantic | var(--dt-font-size-xs) |
--dt-text-code-sm-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-code-sm-weight | semantic | var(--dt-font-weight-regular) |
--dt-text-danger | semantic | var(--dt-color-red-800) |
--dt-text-disabled | semantic | var(--dt-color-neutral-400) |
--dt-text-display-2xl-family | semantic | var(--dt-font-family-sans) |
--dt-text-display-2xl-line | semantic | var(--dt-line-height-fluid) |
--dt-text-display-2xl-size | semantic | var(--dt-font-size-fluid-2xl) |
--dt-text-display-2xl-tracking | semantic | var(--dt-tracking-tightest) |
--dt-text-display-2xl-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-display-lg-family | semantic | var(--dt-font-family-sans) |
--dt-text-display-lg-line | semantic | var(--dt-line-height-7xl) |
--dt-text-display-lg-size | semantic | var(--dt-font-size-7xl) |
--dt-text-display-lg-tracking | semantic | var(--dt-tracking-tightest) |
--dt-text-display-lg-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-display-md-family | semantic | var(--dt-font-family-sans) |
--dt-text-display-md-line | semantic | var(--dt-line-height-6xl) |
--dt-text-display-md-size | semantic | var(--dt-font-size-6xl) |
--dt-text-display-md-tracking | semantic | var(--dt-tracking-tightest) |
--dt-text-display-md-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-display-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-display-sm-line | semantic | var(--dt-line-height-5xl) |
--dt-text-display-sm-size | semantic | var(--dt-font-size-5xl) |
--dt-text-display-sm-tracking | semantic | var(--dt-tracking-tighter) |
--dt-text-display-sm-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-display-xl-family | semantic | var(--dt-font-family-sans) |
--dt-text-display-xl-line | semantic | var(--dt-line-height-fluid) |
--dt-text-display-xl-size | semantic | var(--dt-font-size-fluid-xl) |
--dt-text-display-xl-tracking | semantic | var(--dt-tracking-tightest) |
--dt-text-display-xl-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-eyebrow-family | semantic | var(--dt-font-family-secondary) |
--dt-text-eyebrow-line | semantic | var(--dt-line-height-2xs) |
--dt-text-eyebrow-size | semantic | var(--dt-font-size-2xs) |
--dt-text-eyebrow-tracking | semantic | var(--dt-tracking-wider) |
--dt-text-eyebrow-weight | semantic | var(--dt-font-weight-semibold) |
--dt-text-heading-lg-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-lg-line | semantic | var(--dt-line-height-3xl) |
--dt-text-heading-lg-size | semantic | var(--dt-font-size-3xl) |
--dt-text-heading-lg-tracking | semantic | var(--dt-tracking-tight) |
--dt-text-heading-lg-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-heading-md-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-md-line | semantic | var(--dt-line-height-2xl) |
--dt-text-heading-md-size | semantic | var(--dt-font-size-2xl) |
--dt-text-heading-md-tracking | semantic | var(--dt-tracking-tight) |
--dt-text-heading-md-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-heading-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-sm-line | semantic | var(--dt-line-height-xl) |
--dt-text-heading-sm-size | semantic | var(--dt-font-size-xl) |
--dt-text-heading-sm-tracking | semantic | var(--dt-tracking-snug) |
--dt-text-heading-sm-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-heading-xl-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-xl-line | semantic | var(--dt-line-height-4xl) |
--dt-text-heading-xl-size | semantic | var(--dt-font-size-4xl) |
--dt-text-heading-xl-tracking | semantic | var(--dt-tracking-tighter) |
--dt-text-heading-xl-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-heading-xs-family | semantic | var(--dt-font-family-sans) |
--dt-text-heading-xs-line | semantic | var(--dt-line-height-lg) |
--dt-text-heading-xs-size | semantic | var(--dt-font-size-lg) |
--dt-text-heading-xs-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-heading-xs-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-headline | semantic | var(--dt-text-primary) |
--dt-text-info | semantic | var(--dt-color-cyan-900) |
--dt-text-inverse | semantic | var(--dt-color-neutral-050) |
--dt-text-label-lg-family | semantic | var(--dt-font-family-secondary) |
--dt-text-label-lg-line | semantic | var(--dt-line-height-md) |
--dt-text-label-lg-size | semantic | var(--dt-font-size-md) |
--dt-text-label-lg-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-label-lg-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-label-md-family | semantic | var(--dt-font-family-secondary) |
--dt-text-label-md-line | semantic | var(--dt-line-height-sm) |
--dt-text-label-md-size | semantic | var(--dt-font-size-sm) |
--dt-text-label-md-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-label-md-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-label-sm-family | semantic | var(--dt-font-family-secondary) |
--dt-text-label-sm-line | semantic | var(--dt-line-height-xs) |
--dt-text-label-sm-size | semantic | var(--dt-font-size-xs) |
--dt-text-label-sm-tracking | semantic | var(--dt-tracking-normal) |
--dt-text-label-sm-weight | semantic | var(--dt-font-weight-medium) |
--dt-text-link | semantic | var(--dt-color-neutral-900) |
--dt-text-link-brand | semantic | var(--dt-color-primary-700) |
--dt-text-link-brand-hover | semantic | var(--dt-color-primary-800) |
--dt-text-link-hover | semantic | var(--dt-color-neutral-700) |
--dt-text-link-visited | semantic | var(--dt-color-neutral-700) |
--dt-text-on-action | semantic | var(--dt-color-white) |
--dt-text-on-action-brand | semantic | var(--dt-color-white) |
--dt-text-on-action-brand-secondary | semantic | var(--dt-color-white) |
--dt-text-on-action-danger | semantic | var(--dt-color-white) |
--dt-text-on-action-disabled | semantic | var(--dt-color-neutral-400) |
--dt-text-on-action-ghost | semantic | var(--dt-color-neutral-900) |
--dt-text-on-action-secondary | semantic | var(--dt-color-neutral-900) |
--dt-text-on-brand | semantic | var(--dt-color-white) |
--dt-text-on-brand-muted | semantic | var(--dt-color-primary-900) |
--dt-text-on-brand-secondary | semantic | var(--dt-color-white) |
--dt-text-on-brand-secondary-muted | semantic | var(--dt-color-secondary-900) |
--dt-text-on-danger | semantic | var(--dt-color-white) |
--dt-text-on-info | semantic | var(--dt-color-white) |
--dt-text-on-scrim | semantic | var(--dt-color-neutral-050) |
--dt-text-on-scrim-brand | semantic | var(--dt-color-primary-200) |
--dt-text-on-scrim-secondary | semantic | var(--dt-color-neutral-200) |
--dt-text-on-scrim-strong | semantic | var(--dt-color-white) |
--dt-text-on-selected | semantic | var(--dt-color-neutral-900) |
--dt-text-on-selected-brand | semantic | var(--dt-color-primary-900) |
--dt-text-on-success | semantic | var(--dt-color-white) |
--dt-text-on-warning | semantic | var(--dt-color-neutral-950) |
--dt-text-primary | semantic | var(--dt-color-neutral-900) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
--dt-text-success | semantic | var(--dt-color-green-800) |
--dt-text-tertiary | semantic | var(--dt-color-neutral-500) |
--dt-text-warning | semantic | var(--dt-color-amber-900) |
--dt-text-wordmark | semantic | var(--dt-text-primary) |
Source
React only, no dependencies, every value a token.
import React from "react";
import { Card } from "../display/Card.jsx";
import { Image } from "../content/Image.jsx";
import { Badge } from "../display/Badge.jsx";
import { IconButton } from "../actions/IconButton.jsx";
import { Price } from "./Price.jsx";
import { Rating } from "./Rating.jsx";
/* Product photography ratios. AspectRatio names the square "square" and
has no 4:5, so both are mapped here; a number passes straight through. */
const RATIOS = { "1:1": "square", "4:5": 4 / 5, "3:4": "3:4", "4:3": "4:3" };
const role = (name) => ({
fontFamily: `var(--dt-text-${name}-family)`,
fontSize: `var(--dt-text-${name}-size)`,
lineHeight: `var(--dt-text-${name}-line)`,
fontWeight: `var(--dt-text-${name}-weight)`,
letterSpacing: `var(--dt-text-${name}-tracking)`,
});
function PlusIcon() {
return (
<svg
viewBox="0 0 24 24" aria-hidden="true" focusable="false"
fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ display: "block", width: "var(--dt-size-icon-md)", height: "var(--dt-size-icon-md)" }}
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
);
}
/* Colour dots, display only. One accessible name lists every colour, the
ones past the limit included, so "+3" is never all a screen reader gets. */
function Swatches({ swatches, max }) {
const shown = swatches.slice(0, max);
const extra = swatches.length - shown.length;
return (
<span
role="img"
aria-label={`Colours: ${swatches.map((s) => s.name).join(", ")}`}
style={{ display: "inline-flex", alignItems: "center", flexWrap: "wrap", gap: "var(--dt-space-inline-2xs)" }}
>
{shown.map((s, i) => (
<span
key={`${i}-${s.name}`}
title={s.name}
style={{
display: "block", flex: "none", boxSizing: "border-box",
width: "var(--dt-size-icon-sm)", height: "var(--dt-size-icon-sm)",
borderRadius: "var(--dt-radius-pill)",
background: s.color,
border: "var(--dt-border-width-default) solid var(--dt-swatch-border)",
}}
/>
))}
{extra > 0 && (
<span style={{ ...role("body-xs"), color: "var(--dt-product-subtitle-color)", fontVariantNumeric: "tabular-nums" }}>
+{extra}
</span>
)}
</span>
);
}
export function ProductCard({
name,
href,
image,
ratio = "1:1",
price,
compareAt,
currency,
locale,
rating,
badge,
subtitle,
swatches,
maxSwatches = 5,
soldOut = false,
soldOutLabel = "Sold out",
action,
onQuickAdd,
quickAddLabel,
layout = "vertical",
style,
onMouseEnter,
onMouseLeave,
...rest
}) {
const [hover, setHover] = React.useState(false);
const horizontal = layout === "horizontal";
const linked = !!href;
const r = typeof ratio === "number" ? ratio : RATIOS[ratio] || RATIOS["1:1"];
const corner = "var(--dt-space-inset-xs)";
/* A string badge becomes a solid neutral Badge, which reads on any photo;
pass a Badge of your own for another tone. Sold out takes its place. */
const flag = soldOut
? <Badge tone="neutral">{soldOutLabel}</Badge>
: typeof badge === "string" || typeof badge === "number"
? <Badge tone="neutral" variant="solid">{badge}</Badge>
: badge;
/* A sold-out product can't be added: the consumer's action is disabled
when it is an element that takes a disabled prop, such as Button. */
const act = soldOut && React.isValidElement(action) ? React.cloneElement(action, { disabled: true }) : action;
/* The photo comes after the text in the source and before it on screen
(order -1), so a screen reader meets the name first and the quick-add
button after the link. */
const media = (
<div style={{ position: "relative", minWidth: 0, order: -1 }}>
{image && image.src ? (
<Image src={image.src} alt={image.alt} ratio={r} radius="media" />
) : (
<Image alt="" placeholder={name} ratio={r} radius="media" aria-hidden="true" />
)}
{soldOut && (
<div
aria-hidden="true"
style={{ position: "absolute", inset: 0, borderRadius: "var(--dt-radius-media)", background: "var(--dt-product-soldout-overlay)" }}
/>
)}
{flag && (
<div style={{ position: "absolute", insetBlockStart: corner, insetInlineStart: corner, display: "flex", flexWrap: "wrap", gap: "var(--dt-space-inline-2xs)" }}>
{flag}
</div>
)}
{onQuickAdd && (
<IconButton
label={quickAddLabel || `Add ${name} to cart`}
variant="solid"
size="md"
disabled={soldOut}
onClick={onQuickAdd}
style={{
position: "absolute", insetBlockEnd: corner, insetInlineEnd: corner, zIndex: 1,
borderRadius: "var(--dt-radius-pill)", boxShadow: soldOut ? "none" : "var(--dt-elevation-2)",
}}
>
<PlusIcon />
</IconButton>
)}
</div>
);
const body = (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)", minWidth: 0, flex: horizontal ? undefined : 1 }}>
<span
style={{
...role("label-lg"), color: "var(--dt-card-fg)",
display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 2, overflow: "hidden",
}}
>
{linked ? (
<a href={href} style={{ color: "inherit", textDecoration: hover ? "underline" : "none", textUnderlineOffset: "0.2em" }}>{name}</a>
) : name}
</span>
{subtitle && <span style={{ ...role("body-sm"), color: "var(--dt-product-subtitle-color)" }}>{subtitle}</span>}
<Price amount={price} compareAt={compareAt} currency={currency} locale={locale} />
{rating && <Rating value={rating.value} count={rating.count} locale={locale} size="sm" style={{ alignSelf: "flex-start" }} />}
{swatches && swatches.length > 0 && <Swatches swatches={swatches} max={maxSwatches} />}
{act && (
<div style={{ marginTop: horizontal ? "var(--dt-space-stack-xs)" : "auto", paddingTop: horizontal ? undefined : "var(--dt-space-stack-xs)", position: "relative", zIndex: 1 }}>
{act}
</div>
)}
</div>
);
return (
<Card
href={href}
onMouseEnter={(e) => { setHover(true); if (onMouseEnter) onMouseEnter(e); }}
onMouseLeave={(e) => { setHover(false); if (onMouseLeave) onMouseLeave(e); }}
style={{
padding: "var(--dt-space-inset-sm)",
height: "100%", boxSizing: "border-box", minWidth: 0,
boxShadow: linked && hover ? "var(--dt-card-elevation-hover)" : "var(--dt-card-elevation)",
...style,
}}
{...rest}
>
<div
style={horizontal
? { display: "grid", gridTemplateColumns: "min(33%, calc(var(--dt-size-avatar-xl) * 2.5)) minmax(0, 1fr)", gap: "var(--dt-space-inline-md)", alignItems: "start", flex: 1 }
: { display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-sm)", flex: 1 }}
>
{body}
{media}
</div>
</Card>
);
}