Product Card
The card a product grid is made of. It composes image, price, rating, badge and button — so the pieces you already know keep behaving the way you already know.
Brass desk lamp
Warm, dimmable, and heavy enough to stay where you put it.
Prices and sales
Pass price, and compare-at for what it used to cost:
<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 two amounts read as
a pair for a screen reader as well as for the eye. <del> maps to role="deletion",
which screen readers do not announce by default, so price
prefixes the struck amount with a visually-hidden "Previous price" — announced as
content, not as a label on the wrapper. The "Sale" badge on the corner is therefore
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:
<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.
G
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:
<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 |
|---|---|---|---|
level |
int |
3 |
Heading level for the title, 1–6. The default is what it has always rendered — a card in a grid of products — so nothing moves unless you set it. Set it to match the surrounding outline: a skipped level is what heading-order reports, and that rule sits in axe's best-practice tag rather than the WCAG tags most suites run, so it is invisible to an axe sweep and visible in Lighthouse. |
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: the struck amount carries a visually-hidden "Previous price" prefix, 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 CTA's accessible name starts with the words printed on it and appends the product after them ("Add to cart: Brass desk lamp"), so someone driving the page by voice can say the label they can read and reach the right button on a grid of cards.
- 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 |
|---|---|
| Tab | Move to the product name, then to the CTA. |
| Enter | Follow the name to the product page. |
| Enter / Space | 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.