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.

Commerce ProductCard.jsx ProductCard.d.ts ProductCard.md

Playground

Change a prop and watch it. The code below follows along.

Install 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 Price and QuantityStepper in a row.
  • The content isn't a product for sale. Use Card.
  • You are on the product page itself. Use ProductGallery, Price, Rating and VariantPicker there.

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

PropWhat 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.
compareAtA sale. Price strikes through the old price and announces "Was …, now …".
badgeA flag over the top corner of the photo: "New", "-20%". A string becomes a solid neutral Badge; pass your own Badge for another tone.
swatchesColour dots, display only. Past maxSwatches (5) the rest collapse into "+3".
soldOutWashes out the photo, shows soldOutLabel ("Sold out") in place of the badge, disables the quick-add button, and clones action with disabled: true.
onQuickAddA round add button over the photo's bottom corner, named "Add {name} to cart". For dense grids.
actionAnything at the foot of the card, usually an "Add to cart" Button. For roomier cards.

Composition

  • Built on Card: its surface, border, radius and the href pattern. The name is the one real link; an aria-hidden copy 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 an AspectRatio): the frame reserves its space before the photo loads, and without image it shows the system's placeholder.
  • The price is a Price, the rating a small display Rating, the flag a Badge, the quick add an IconButton.
  • Put cards in a Grid, or a CSS grid of repeat(auto-fill, minmax(…, 1fr)). The card fills its cell's height. Pass as="li" inside a list.
  • Don't put other links or controls in the text: only action and 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 is aria-hidden and 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" by Rating.
  • "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

  • name is the product's name as the catalogue has it, sentence case. It clamps to two lines.
  • subtitle is one short line: the brand, the variant ("Fern glaze") or a summary ("8 colours").
  • badge is 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.
TokenTierDeclared as
--dt-card-elevationcomponentvar(--dt-elevation-0)
--dt-card-elevation-hovercomponentvar(--dt-elevation-1)
--dt-card-fgcomponentvar(--dt-text-primary)
--dt-product-soldout-overlaycomponentvar(--dt-surface-glass)
--dt-product-subtitle-colorcomponentvar(--dt-text-secondary)
--dt-swatch-bordercomponentvar(--dt-border-strong)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-elevation-2semanticvar(--dt-shadow-raw-2)
--dt-radius-mediasemanticvar(--dt-radius-raw-12)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-size-avatar-xlsemanticvar(--dt-dim-16)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-size-icon-smsemanticvar(--dt-dim-4)
--dt-space-inline-2xssemanticvar(--dt-dim-1)
--dt-space-inline-mdsemanticvar(--dt-dim-4)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-space-stack-smsemanticvar(--dt-dim-3)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--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>
  );
}