Product Card
The product tile a listing, carousel, recommendation rail or wishlist repeats: media, badges, name, rating, price and actions on a card, in grid, row and compact layouts.
Preview
@php
$products = [
['name' => 'Aero Knit Runner', 'category' => 'Footwear', 'price' => '118', 'compareAt' => '148', 'rating' => 4.5, 'reviews' => 214, 'badge' => 'Save 20%', 'gradient' => 'from-primary/20 to-muted'],
['name' => 'Merino Travel Crew', 'category' => 'Knitwear', 'price' => '96', 'rating' => 5, 'reviews' => 88, 'gradient' => 'from-secondary/30 to-muted'],
['name' => 'Cascade Weatherproof Shell', 'category' => 'Outerwear', 'price' => '214', 'rating' => 4, 'reviews' => 132, 'soldOut' => true, 'gradient' => 'from-accent/40 to-muted'],
];
@endphp
<div class="flex w-full flex-col gap-8">
<ul role="list" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
@foreach ($products as $product)
<li><x-ui.product-card :product="$product" /></li>
@endforeach
</ul>
<x-ui.product-card :product="$products[0] + ['description' => 'Lightweight mesh upper with responsive cushioning for all-day comfort.']" layout="row" />
<div class="max-w-sm">
<x-ui.product-card :product="$products[1]" layout="compact" />
</div>
</div>
Installation
php artisan ui:add product-card
Registry contract
php artisan ui:add product-card
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/product-card.blade.php
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Product Card (`product-card`)
The product tile a listing, carousel, recommendation rail or wishlist repeats: media, badges, name, rating, price and actions on a card, in grid, row and compact layouts.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add product-card
```
## Usage
```blade
@php
$products = [
['name' => 'Aero Knit Runner', 'category' => 'Footwear', 'price' => '118', 'compareAt' => '148', 'rating' => 4.5, 'reviews' => 214, 'badge' => 'Save 20%', 'gradient' => 'from-primary/20 to-muted'],
['name' => 'Merino Travel Crew', 'category' => 'Knitwear', 'price' => '96', 'rating' => 5, 'reviews' => 88, 'gradient' => 'from-secondary/30 to-muted'],
['name' => 'Cascade Weatherproof Shell', 'category' => 'Outerwear', 'price' => '214', 'rating' => 4, 'reviews' => 132, 'soldOut' => true, 'gradient' => 'from-accent/40 to-muted'],
];
@endphp
<div class="flex w-full flex-col gap-8">
<ul role="list" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
@foreach ($products as $product)
<li><x-ui.product-card :product="$product" /></li>
@endforeach
</ul>
<x-ui.product-card :product="$products[0] + ['description' => 'Lightweight mesh upper with responsive cushioning for all-day comfort.']" layout="row" />
<div class="max-w-sm">
<x-ui.product-card :product="$products[1]" layout="compact" />
</div>
</div>
```
## Props
- `product` (array, default `[]`) — The product: name, href, category, description, price, compareAt, currency, image, imageAlt, gradient (a token gradient for the placeholder, e.g. 'from-primary/20 to-muted'), rating, reviews, badge, badgeVariant, soldOut. `sale` and `reviewCount` are accepted aliases of badge and reviews.
- `layout` (grid|row|compact, default `grid`) — `grid` stacks media over body with a full-width action; `row` puts a thumbnail at the inline start and price/actions in an end column; `compact` is a thumbnail-name-price line with no description, rating or add-to-cart.
- `showRating` (bool, default `true`) — Renders the display-mode rating and review count when the product carries a rating. Never shown in the compact layout.
- `showWishlist` (bool, default `true`) — Renders the save-to-wishlist icon button (over the media in the grid, beside the actions otherwise).
- `showActions` (bool, default `true`) — Renders the default add-to-cart button (disabled and relabelled when sold out). Ignored by the compact layout unless an `actions` slot is given.
- `currency` (string, default `USD`) — ISO 4217 code for numeric prices; a product-level `currency` key overrides it.
- `locale` (string, default `en-US`) — Locale handed to price / money-value for numeric amounts.
- `headingLevel` (h2|h3|h4, default `h3`) — Element for the product name, so the tile fits the page outline.
- `addLabel` (string|null, default `null`) — Label of the default add-to-cart button; defaults to a localized 'Add to cart'.
## Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Rendering products in a grid, a horizontal carousel, a recommendation rail, a wishlist or a search result list.
- You need one tile that already handles sale badges, sold-out state, a display rating, a compare-at price and the wishlist/add-to-cart actions.
## Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- The product needs option pickers, a gallery and a long description — that is a product overview block, not a tile.
- The row is a cart line with a quantity control — compose card, number-field and price directly.
## Anti-patterns
- Using cards for dense cross-item comparison
## Rules
- Use the `<brok:product-card>` tag (or `<x-ui.product-card>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/product-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/product-card.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
<div class="max-w-xs">
<x-ui.product-card
:product="[
'name' => 'The Extraordinarily Long Product Name That Wraps Across Several Lines Before It Is Clamped',
'category' => 'A category label that runs long',
'description' => 'A deliberately long description that verifies wrapping, overflow and clamping without clipping the price, the rating or the call to action beneath it.',
'price' => '1234567.89',
'compareAt' => '1999999.99',
'currency' => 'EUR',
'rating' => 3.5,
'reviews' => 12345,
'badge' => 'Limited-time launch offer',
]"
locale="de-DE"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| product | array | [] | The product: name, href, category, description, price, compareAt, currency, image, imageAlt, gradient (a token gradient for the placeholder, e.g. 'from-primary/20 to-muted'), rating, reviews, badge, badgeVariant, soldOut. `sale` and `reviewCount` are accepted aliases of badge and reviews. |
| layout | grid | row | compact | grid | `grid` stacks media over body with a full-width action; `row` puts a thumbnail at the inline start and price/actions in an end column; `compact` is a thumbnail-name-price line with no description, rating or add-to-cart. |
| showRating | bool | true | Renders the display-mode rating and review count when the product carries a rating. Never shown in the compact layout. |
| showWishlist | bool | true | Renders the save-to-wishlist icon button (over the media in the grid, beside the actions otherwise). |
| showActions | bool | true | Renders the default add-to-cart button (disabled and relabelled when sold out). Ignored by the compact layout unless an `actions` slot is given. |
| currency | string | USD | ISO 4217 code for numeric prices; a product-level `currency` key overrides it. |
| locale | string | en-US | Locale handed to price / money-value for numeric amounts. |
| headingLevel | h2 | h3 | h4 | h3 | Element for the product name, so the tile fits the page outline. |
| addLabel | string | null | null | Label of the default add-to-cart button; defaults to a localized 'Add to cart'. |
Slots
media— Replaces the image / gradient placeholder inside the media box.badges— Replaces the default sale / sold-out badge stack over the media.actions— Replaces the default add-to-cart button (a form, a quick-view trigger, a quantity control).
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Server-rendered Blade, no JavaScript of its own: the rating is display-mode and prices format through money-value.
- Sold out sets data-sold-out, swaps the badge and disables/relabels the add-to-cart button.
- Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Present a self-contained content object with local context.
Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Rendering products in a grid, a horizontal carousel, a recommendation rail, a wishlist or a search result list.
- You need one tile that already handles sale badges, sold-out state, a display rating, a compare-at price and the wishlist/add-to-cart actions.
Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- The product needs option pickers, a gallery and a long description — that is a product overview block, not a tile.
- The row is a cart line with a quantity control — compose card, number-field and price directly.
Use instead
- List for compact scanning
- Table for exact comparison
Anti-patterns
- Using cards for dense cross-item comparison
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The product name is a heading (level configurable) wrapping the link; the wishlist and add-to-cart buttons carry the product name in their accessible label.
- The placeholder media is aria-hidden; a real image takes imageAlt (falling back to the name).
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'product' => [],
'layout' => 'grid',
'showRating' => true,
'showWishlist' => true,
'showActions' => true,
'currency' => 'USD',
'locale' => 'en-US',
'headingLevel' => 'h3',
'addLabel' => null,
])
{{--
Product card: the tile a listing, a carousel, a recommendation strip and a
wishlist all repeat. It composes card (surface), badge (sale / sold out),
rating (display mode, no Alpine), price (money-value under the hood) and
button (wishlist + add to cart), so a block passes data and slots instead
of re-deriving the tile.
`product` keys: name, href, category, description, price, compareAt, image,
imageAlt, gradient (a token gradient such as "from-primary/20 to-muted" for
the placeholder), rating, reviews, badge, badgeVariant, soldOut.
Layouts share one DOM outline: media → body (category, name, description,
rating, price) → actions. `grid` stacks them; `row` puts the media at the
inline start and the price/actions in an end column; `compact` is the
thumbnail-and-price line of a recommendation rail or a cart, with no
description, rating or actions unless asked for.
Slots: `media` replaces the image/placeholder, `badges` replaces the
default badge stack, `actions` replaces the add-to-cart button.
--}}
@php
// Named slots arrive as variables only when the caller passes them.
$media ??= null;
$badges ??= null;
$actions ??= null;
$layout = in_array($layout, ['grid', 'row', 'compact'], true) ? $layout : 'grid';
$headingLevel = in_array($headingLevel, ['h2', 'h3', 'h4'], true) ? $headingLevel : 'h3';
$name = (string) ($product['name'] ?? '');
$href = $product['href'] ?? '#';
$soldOut = ! empty($product['soldOut']);
$badge = $product['badge'] ?? $product['sale'] ?? null;
$badgeVariant = $product['badgeVariant'] ?? 'destructive';
$reviews = $product['reviews'] ?? $product['reviewCount'] ?? null;
$rating = $product['rating'] ?? null;
$currency = $product['currency'] ?? $currency;
$isCompact = $layout === 'compact';
$isRow = $layout !== 'grid';
// The media box: square in the grid, a fixed thumbnail in the row layouts.
$mediaBox = match ($layout) {
'grid' => 'relative aspect-square w-full overflow-hidden rounded-t-lg',
'row' => 'relative size-24 shrink-0 overflow-hidden rounded-md sm:size-32',
default => 'relative size-16 shrink-0 overflow-hidden rounded-md',
};
$placeholder = 'size-full bg-gradient-to-br '.($product['gradient'] ?? 'from-muted to-secondary/20');
$addLabel ??= __('Add to cart');
$wishlistLabel = __('Save :name to wishlist', ['name' => $name]);
$addAriaLabel = __('Add :name to cart', ['name' => $name]);
$rootLayout = match ($layout) {
'grid' => 'flex h-full flex-col overflow-hidden',
'row' => 'flex gap-4 p-4 sm:gap-6',
default => 'flex items-center gap-4 p-4',
};
$heartIcon = '<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" /></svg>';
@endphp
<x-ui.card
as="article"
data-slot="product-card"
data-layout="{{ $layout }}"
:data-sold-out="$soldOut ? 'true' : null"
{{ $attributes->merge(['class' => $rootLayout]) }}
>
{{-- Media --}}
<div data-slot="product-card-media" class="{{ $mediaBox }}">
@if ($media)
{{ $media }}
@elseif (! empty($product['image']))
<img src="{{ $product['image'] }}" alt="{{ $product['imageAlt'] ?? $name }}" loading="lazy" decoding="async" class="size-full object-cover" />
@else
<div class="{{ $placeholder }}" aria-hidden="true"></div>
@endif
@if (! $isCompact)
<div data-slot="product-card-badges" class="absolute start-2 top-2 flex flex-col items-start gap-1">
@if ($badges)
{{ $badges }}
@else
@if ($soldOut)
<x-ui.badge variant="secondary" size="sm" class="shadow-sm">{{ __('Sold out') }}</x-ui.badge>
@elseif ($badge)
<x-ui.badge :variant="$badgeVariant" size="sm" class="shadow-sm">{{ __($badge) }}</x-ui.badge>
@endif
@endif
</div>
@if ($showWishlist && $layout === 'grid')
<div data-slot="product-card-wishlist" class="absolute end-2 top-2">
<x-ui.button type="button" variant="ghost" size="sm" :icon-label="$wishlistLabel" class="rounded-full bg-background/70 text-foreground backdrop-blur-sm hover:bg-background/90">
{!! $heartIcon !!}
</x-ui.button>
</div>
@endif
@endif
</div>
{{-- Body --}}
<div data-slot="product-card-body" class="{{ $layout === 'grid' ? 'flex flex-1 flex-col gap-2 p-4' : 'flex min-w-0 flex-1 flex-col gap-2' }}">
@if (! $isCompact && ! empty($product['category']))
<p data-slot="product-card-category" class="text-xs font-medium uppercase tracking-wider text-muted-foreground">{{ __($product['category']) }}</p>
@endif
<{{ $headingLevel }} data-slot="product-card-name" class="{{ $isCompact ? 'line-clamp-2 text-sm font-medium leading-snug text-foreground' : 'line-clamp-2 text-sm font-semibold leading-snug text-foreground' }}">
<a href="{{ $href }}" class="rounded-sm hover:text-link focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ __($name) }}</a>
</{{ $headingLevel }}>
@if (! $isCompact && ! empty($product['description']))
<p data-slot="product-card-description" class="line-clamp-2 text-sm text-muted-foreground">{{ __($product['description']) }}</p>
@endif
@if ($showRating && ! $isCompact && $rating !== null)
<div data-slot="product-card-rating" class="flex items-center gap-2">
<x-ui.rating :value="$rating" :step="0.5" size="sm" display :label="$reviews !== null ? __(':rating out of 5 stars, :count reviews', ['rating' => $rating, 'count' => $reviews]) : null" />
@if ($reviews !== null)
<span class="text-sm text-muted-foreground" aria-hidden="true">({{ $reviews }})</span>
@endif
</div>
@endif
@if ($layout === 'grid')
<x-ui.price :amount="$product['price'] ?? null" :compare-at="$product['compareAt'] ?? null" :currency="$currency" :locale="$locale" size="md" />
@elseif ($isCompact)
<x-ui.price :amount="$product['price'] ?? null" :compare-at="$product['compareAt'] ?? null" :currency="$currency" :locale="$locale" size="sm" />
@endif
@if ($showActions && $layout === 'grid')
<div data-slot="product-card-actions" class="mt-auto pt-2">
@if ($actions)
{{ $actions }}
@else
<x-ui.button type="button" size="sm" class="w-full" :disabled="$soldOut" :aria-label="$addAriaLabel">{{ $soldOut ? __('Sold out') : $addLabel }}</x-ui.button>
@endif
</div>
@endif
</div>
{{-- End column: price and actions sit together in the row layout; the
compact line keeps only the wishlist button. --}}
@if ($isRow)
<div data-slot="product-card-aside" class="{{ $isCompact ? 'flex shrink-0 items-center gap-2' : 'flex shrink-0 flex-col items-end gap-2' }}">
@if ($layout === 'row')
<x-ui.price :amount="$product['price'] ?? null" :compare-at="$product['compareAt'] ?? null" :currency="$currency" :locale="$locale" size="md" class="justify-end" />
@endif
@if ($showWishlist || ($showActions && $layout === 'row') || $actions)
<div data-slot="product-card-actions" class="flex items-center gap-2">
@if ($showWishlist)
<span data-slot="product-card-wishlist" class="inline-flex">
<x-ui.button type="button" variant="ghost" size="sm" :icon-label="$wishlistLabel">
{!! $heartIcon !!}
</x-ui.button>
</span>
@endif
@if ($actions)
{{ $actions }}
@elseif ($showActions && $layout === 'row')
<x-ui.button type="button" size="sm" :disabled="$soldOut" :aria-label="$addAriaLabel">{{ $soldOut ? __('Sold out') : $addLabel }}</x-ui.button>
@endif
</div>
@endif
</div>
@endif
</x-ui.card>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0