Skip to content
Brok UI

Loading…

No results

Product Card

Open source

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.

Version
v1.0.1
Stability
stable
License
MIT
Related
Card
Price
Rating
Badge
Button
Option Group
Color Swatch Selector

Preview

  • Save 20%

    Footwear

    Aero Knit Runner

    Sale price $118.00 Original price $148.00
Save 20%

Footwear

Aero Knit Runner

Lightweight mesh upper with responsive cushioning for all-day comfort.

Sale price $118.00 Original price $148.00
previews.components.product-card.default.blade.php 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>

Installation

terminal
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.

  • blade resources/views/components/ui/product-card.blade.php
Registry dependencies
card badge rating price button
Packages
composer: jml/brok:^0.2

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.

product-card.md
# 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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

product-card product-card-actions product-card-aside product-card-badges product-card-body product-card-category product-card-description product-card-media product-card-name product-card-rating product-card-wishlist

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

Card and content widget

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
product-card product-card-media product-card-badges product-card-wishlist product-card-body product-card-category product-card-name product-card-description product-card-rating product-card-actions product-card-aside
Theming hooks
product-card

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Safe

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.

resources/views/components/ui/product-card.blade.php Blade
@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