---
title: Product Card
description: The ecommerce keystone — image, price with compare-at, rating, stock state, CTA.
category: Data Display
related:
  - /components/price
  - /components/rating
  - /components/image
  - /components/badge
visibility: guest
draft: false
---

# Product Card

The card a product grid is made of. It composes [image](/components/image),
[price](/components/price), [rating](/components/rating),
[badge](/components/badge) and [button](/components/button) — so the pieces you
already know keep behaving the way you already know.

:::preview{title="Product cards on sale, low stock and sold out"}
<x-wirekit::grid cols="1 sm:2 lg:3" gap="lg">
    <x-wirekit::product-card
        name="Brass desk lamp"
        href="#"
        image="/placeholder/400x400?bg=fde68a&fg=78350f&label=Lamp&gradient=none"
        :price="3900"
        :compare-at="5900"
        :minor-units="true"
        currency="EUR"
        :rating="4.5"
        :review-count="128"
        description="Warm, dimmable, and heavy enough to stay where you put it."
    />
    <x-wirekit::product-card
        name="Walnut notebook"
        href="#"
        image="/placeholder/400x400?bg=d6d3d1&fg=1c1917&label=Notebook&gradient=none"
        :price="1200"
        :minor-units="true"
        currency="EUR"
        :rating="4"
        :review-count="12"
        availability="low-stock"
        description="Dotted, lies flat, survives a bag."
    />
    <x-wirekit::product-card
        name="Studio chair"
        href="#"
        image="/placeholder/400x400?bg=bfdbfe&fg=1e3a8a&label=Chair&gradient=none"
        :price="24900"
        :minor-units="true"
        currency="EUR"
        :rating="5"
        :review-count="41"
        availability="out-of-stock"
        description="The one you stop noticing after a week."
    />
</x-wirekit::grid>
:::

## Prices and sales

Pass `price`, and `compare-at` for what it used to cost:

```blade
<x-wirekit::product-card name="Brass desk lamp" :price="3900" :compare-at="5900" :minor-units="true" currency="EUR" />
```

The old price renders struck through in a real `<del>`, and the whole story is
**announced**: "€39.00, was €59.00, 34% off". That comes from
[price](/components/price), which already does it properly — so the "Sale" badge on
the corner is pure decoration and carries nothing a screen-reader user would miss.

A `compare-at` that is equal to or lower than `price` is **not** treated as a sale.
It is not a discount, and striking it through would be a claim the numbers do not
support.

## Stock

| `availability` | What happens |
|----------------|--------------|
| `in-stock` | Nothing (the default) |
| `low-stock` | A "Low stock" badge. The CTA stays live — it is a nudge, not a wall. |
| `out-of-stock` | An "Out of stock" badge, and the CTA is disabled and says why |

Both states are **words**, not a wash over the image. A reader who cannot resolve a
gray tint sees a perfectly normal product.

## Images

`image-alt` defaults to empty on purpose. The product name is already right there
in text, so an alt that repeats it makes a screen reader read the product twice.

Pass one when the image shows something the name does not:

```blade
<x-wirekit::product-card
    name="Brass desk lamp"
    image="/img/lamp.jpg"
    image-alt="Lit and angled over a dark desk"
    :price="3900"
/>
```

## The link

`href` makes the whole card clickable — but the **link is the product name**, not
the card.

That is deliberate. Wrapping the card in one anchor makes the link's accessible
name the entire card (image, price, rating, button text, all read as one
breathless sentence), and it swallows the add-to-cart button, which cannot nest
inside an anchor at all. The pointer gets the big target; the screen reader gets a
short, honest name.

## A product without a photo

`image` takes a URL, which is the ordinary case. When the media is not a URL — a painted
placeholder, a color field, a `<video>`, a chart — put it in the `media` slot instead. The slot
wins over `image` when both are given.

The `badge` slot sits beside the ones the card works out for itself. Those answer two questions,
on sale and in stock; anything else — "New", "Limited", "Last pair" — goes here, and the two stack
rather than landing on top of each other.

:::preview{title="A card whose media is drawn, not photographed"}
<x-wirekit::product-card name="Gift card" :price="5000" href="#">
    <x-slot:media>
        <x-wirekit::aspect-ratio ratio="1/1" style="background: var(--color-wk-bg-subtle); display: grid; place-items: center;">
            <x-wirekit::text size="xl" weight="semibold" intent="muted">G</x-wirekit::text>
        </x-wirekit::aspect-ratio>
    </x-slot:media>
    <x-slot:badge>
        <x-wirekit::badge intent="info" surface="solid" size="sm">New</x-wirekit::badge>
    </x-slot:badge>
</x-wirekit::product-card>
:::

With neither `image` nor `media`, the card draws no media area at all. That is deliberate: a
placeholder invented here would appear on every card that legitimately has no picture, and a
caller who wants one has the slot to put exactly the box they mean.

## A different action

The `action` slot replaces the default CTA:

```blade
<x-wirekit::product-card name="Studio chair" :price="24900" availability="out-of-stock">
    <x-slot:action>
        <x-wirekit::button intent="neutral" class="w-full">Notify me</x-wirekit::button>
    </x-slot:action>
</x-wirekit::product-card>
```

## Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `name` | `string` | `''` | The product. Rendered as the heading. |
| `href` | `string\|null` | `null` | Product page. Makes the card clickable; the name is the link. |
| `image` | `string\|null` | `null` | Cover image. |
| `imageAlt` | `string\|null` | `null` | Accessible name for the image. Empty by default — the name is already in text. |
| `price` | `int\|float\|null` | `null` | Current price. |
| `compareAt` | `int\|float\|null` | `null` | What it used to cost. Only treated as a sale when it is higher. |
| `currency` | `string` | config | Currency code. |
| `minorUnits` | `bool` | `false` | Treat the amounts as cents. |
| `rating` | `int\|float\|null` | `null` | Average out of 5. |
| `reviewCount` | `int\|null` | `null` | How many people rated it. |
| `description` | `string\|null` | `null` | Short line under the name. Clamped to two lines. |
| `availability` | `string` | `'in-stock'` | `in-stock`, `low-stock`, `out-of-stock`. |
| `scope` | `string\|null` | `null` | Class-scope override. |

## Slots

| Slot | Description |
|------|-------------|
| `media` | Replaces the cover image. For media that is not a URL. Wins over `image`. |
| `badge` | An extra badge over the media, stacked under the derived one. |
| `action` | Replaces the default add-to-cart button. |

## Accessibility

- An `<article>`, so a screen reader can move between products as units.
- The **name** is the link, never the whole card — see above.
- The sale is announced by [price](/components/price) ("was €59.00, 34% off"), so
  it never depends on seeing a strike-through or a red badge.
- Stock states are words in a badge, not a tint.
- A disabled CTA says *why* in its accessible name ("Out of stock: Brass desk
  lamp"). A disabled button with no explanation is a dead end.
- The review count sits next to the stars: "4.5 stars" from two people and from two
  thousand are different claims.
- A product nobody has rated renders no star row at all — an empty row implies zero
  stars, and unrated is not zero.

## Keyboard Interaction

| Key | Action |
|-----|--------|
| <kbd>Tab</kbd> | Move to the product name, then to the CTA. |
| <kbd>Enter</kbd> | Follow the name to the product page. |
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Fire the CTA when it has focus. |

Two stops per card, not one per element — the image, badges and rating are not
focusable, because none of them do anything.
