Skip to content
Nizivo UI

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.

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

product-card.css
@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 ::after is 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 NumberValue with 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, with alt="" 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 uses text/secondary, and Nizivo's text/tertiary is re-pointed to pass anyway.

On this page