Product card
A catalogue tile whose whole card links to the product, with a photo, the title, the price (and on a sale the price before it) and a status Badge. A server composition; it ships no JavaScript.
From Figma 05 Commerce, Product Card (7275:13989; variants Active 7275:13990, Draft
7275:13998, Sold out 7275:14006). Uses Card,
Badge, Text and
NumberValue.
Examples
Catalogue grid
Figma 05's Product Card (7275:13989) as a catalogue grid. Each card is an <article> whose title is its one link: the link's box is stretched over the card, so the whole card is the hit area but the card is a single Tab stop with a short name. A server composition: it ships no JavaScript. The grey block stands in for the product's Image.
- Active
Stoneware mug
$28.00124 in stock
- Active
Linen apron
Sale price $32.00Regular price $42.0040 in stock
- Low stock
Night serum
$52.004 in stock
- Draft
Fig and cedar candle
$34.00Not published
- Sold out
Market tote
$46.00Out of stock
import type { JSX } from 'react';
import { Badge, type BadgeTone } from '@nizivo/react/badge';
import { Card } from '@nizivo/react/card';
import { NumberValue } from '@nizivo/react/number-value';
import { Text } from '@nizivo/react/text';
/** Money as the store's currency: Intl formats it, never string concatenation. */
const USD = { style: 'currency', currency: 'USD' } as const;
/** Publication and inventory are separate facts; the card's one badge is derived from both. */
type Status = 'active' | 'draft' | 'low' | 'sold-out';
/** Each status's words and tone. The words carry the meaning; the tone only repeats it. */
const STATUS: Record<Status, { label: string; tone: BadgeTone }> = {
active: { label: 'Active', tone: 'success' },
draft: { label: 'Draft', tone: 'neutral' },
low: { label: 'Low stock', tone: 'warning' },
'sold-out': { label: 'Sold out', tone: 'critical' },
};
interface Product {
id: string;
title: string;
status: Status;
stock: number;
price: number;
/** The price before a sale, when the product is on sale. */
compareAt?: number;
}
const PRODUCTS: Product[] = [
{ id: 'stoneware-mug', title: 'Stoneware mug', status: 'active', stock: 124, price: 28 },
{
id: 'linen-apron',
title: 'Linen apron',
status: 'active',
stock: 40,
price: 32,
compareAt: 42,
},
{ id: 'night-serum', title: 'Night serum', status: 'low', stock: 4, price: 52 },
{ id: 'fig-candle', title: 'Fig and cedar candle', status: 'draft', stock: 36, price: 34 },
{ id: 'market-tote', title: 'Market tote', status: 'sold-out', stock: 0, price: 46 },
];
/** The price, and on a sale the struck-through original, each named in words for screen readers. */
function Price({
price,
compareAt,
}: {
price: number;
compareAt?: number | undefined;
}): JSX.Element {
if (compareAt === undefined) {
return (
<Text render={<span />} variant="numeric-m">
<NumberValue value={price} format={USD} />
</Text>
);
}
return (
<span className="nzp-product-card-price">
<Text render={<span />} variant="numeric-m" tone="critical">
<span className="nz-visually-hidden">Sale price </span>
<NumberValue value={price} format={USD} />
</Text>
<Text render={<s />} variant="numeric-m" tone="secondary">
<span className="nz-visually-hidden">Regular price </span>
<NumberValue value={compareAt} format={USD} />
</Text>
</span>
);
}
/** The inventory line: a count in words, coloured only when it needs attention. */
function Stock({ product }: { product: Product }): JSX.Element {
if (product.status === 'draft') {
return <Text tone="secondary">Not published</Text>;
}
if (product.stock === 0) {
return <Text tone="critical">Out of stock</Text>;
}
return (
<Text tone={product.status === 'low' ? 'warning' : 'secondary'}>
<NumberValue value={product.stock} /> in stock
</Text>
);
}
/** One catalogue tile: the whole card is the link to the product, through its title. */
function ProductCard({ product }: { product: Product }): JSX.Element {
const status = STATUS[product.status];
return (
<Card.Root className="nzp-product-card" padding="sm" render={<article />}>
<div className="nzp-product-card-media">
<span className="nzp-product-card-image" aria-hidden="true" />
<Badge className="nzp-product-card-badge" tone={status.tone}>
{status.label}
</Badge>
</div>
<div className="nzp-product-card-body">
<div className="nzp-product-card-row">
<Card.Title className="nzp-product-card-title">
<a className="nzp-product-card-link" href={`#${product.id}`}>
{product.title}
</a>
</Card.Title>
<Price price={product.price} compareAt={product.compareAt} />
</div>
<Stock product={product} />
</div>
</Card.Root>
);
}
export function ProductCardExample(): JSX.Element {
return (
<ul className="nzp-product-card-grid" aria-label="Products">
{PRODUCTS.map((product) => (
<li key={product.id}>
<ProductCard product={product} />
</li>
))}
</ul>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
.nzp-product-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
gap: var(--nz-space-4);
margin: 0;
padding: 0;
list-style: none;
}
/* the card is the positioning box of its stretched link */
.nzp-product-card {
position: relative;
gap: var(--nz-space-3);
block-size: 100%;
padding: var(--nz-space-3);
}
.nzp-product-card:has(.nzp-product-card-link:hover) {
border-color: var(--nz-border-strong);
}
/* the focus ring goes round the whole card, the link's hit area */
.nzp-product-card:has(.nzp-product-card-link:focus-visible) {
outline: var(--nz-border-width-focus) solid var(--nz-border-focus);
outline-offset: var(--nz-space-0-5);
}
.nzp-product-card-media {
position: relative;
}
/* concentric with the card: its radius less its padding */
.nzp-product-card-image {
display: block;
aspect-ratio: 1;
border-radius: calc(var(--nz-radius-xl) - var(--nz-space-3));
background: var(--nz-bg-muted);
}
.nzp-product-card-badge {
position: absolute;
inset-block-start: var(--nz-space-2);
inset-inline-start: var(--nz-space-2);
}
.nzp-product-card-body {
display: flex;
flex-direction: column;
gap: var(--nz-space-1);
padding-inline: var(--nz-space-1);
}
.nzp-product-card-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--nz-space-3);
}
.nzp-product-card-title {
flex: 1;
min-inline-size: 0;
font: var(--nz-font-label-l);
letter-spacing: normal;
}
.nzp-product-card-link {
color: inherit;
text-decoration: none;
}
.nzp-product-card-link:hover {
text-decoration: underline;
}
.nzp-product-card-link:focus-visible {
outline: none;
}
/* the stretched link: its box covers the card, so the whole card is one hit area */
.nzp-product-card-link::after {
position: absolute;
inset: 0;
border-radius: inherit;
content: '';
}
.nzp-product-card-price {
display: flex;
flex: none;
flex-direction: column;
align-items: end;
}
}
Notes
- One link, one Tab stop. Each card is an
<article>whose title is a real<a>. The link's::afteris stretched over the card, so the whole card is the hit area and shows the focus ring, but the link's name stays the product's title. Nothing else in the card is interactive: a quick-add or wishlist button inside a link is a nested control, so put it beside the card, not in it. Making the whole card an<a>would read every word on it as the link's name. - Status in words. Figma's Status axis mixes publication (Active, Draft) with inventory (Sold out), and the example table shows a fourth state, Low stock, that the card has no variant for. The example derives one Badge from both facts and says the inventory in a line of its own; the Badge's dot is decorative.
- Sale prices. The struck-through price before a sale is read by screen readers without its
strike-through, so each price has a visually hidden word ("Sale price", "Regular price"). Both are
NumberValuewith the store's currency. - Long titles wrap; the price keeps its line and its column. Figma's title has no truncation and would collide with the price.
- Images. The grey block, a square, stands in for the product's
Image, withalt=""because the title next to it names the product. Its radius is the card's radius less its padding, so the corners stay concentric (Figma's 14 px is off the scale). - Contrast. Figma's inventory line uses
text/tertiary, 4.31:1 on white. The example usestext/secondary, and Nizivo'stext/tertiaryis re-pointed to pass anyway.
Order summary
An order's lines and totals on a Card, the totals a description list with the amounts in tabular figures. A server composition; it ships no JavaScript.
Variant picker
A product's colour and size options as chips of native radios, with an out-of-stock option shown as sold out rather than disabled.