Product Category
Category pages — a banner with a faceted filter sidebar (categories, price, swatches) and a product grid.
Preview
@props([
'title' => 'Running shoes',
'count' => null,
'breadcrumb' => null,
'categories' => null,
'prices' => null,
'colors' => null,
'products' => null,
'currency' => 'USD',
])
@php
$breadcrumb ??= ['Home', 'Footwear', 'Running shoes'];
$categories ??= [
['label' => 'Road running', 'count' => 42, 'checked' => true],
['label' => 'Trail running', 'count' => 28, 'checked' => false],
['label' => 'Racing flats', 'count' => 12, 'checked' => false],
['label' => 'Stability', 'count' => 19, 'checked' => false],
['label' => 'Recovery', 'count' => 7, 'checked' => false],
];
$prices ??= [
['label' => 'Under $80', 'active' => false],
['label' => '$80 – $120', 'active' => true],
['label' => '$120 – $160', 'active' => false],
['label' => '$160+', 'active' => false],
];
// Colour swatches use semantic token classes — never raw colours.
$colors ??= [
['label' => 'Primary', 'class' => 'bg-primary', 'active' => true],
['label' => 'Accent', 'class' => 'bg-accent', 'active' => false],
['label' => 'Secondary', 'class' => 'bg-secondary', 'active' => false],
['label' => 'Muted', 'class' => 'bg-muted', 'active' => false],
['label' => 'Destructive', 'class' => 'bg-destructive', 'active' => false],
['label' => 'Foreground', 'class' => 'bg-foreground', 'active' => false],
];
$products ??= [
['name' => 'Velocity Trail GTX', 'subtitle' => 'All-terrain, Waterproof', 'price' => '148', 'was' => '180', 'rating' => 4.5, 'reviews' => 214, 'badge' => 'Bestseller', 'badgeVariant' => 'default'],
['name' => 'Aero Pace 2', 'subtitle' => 'Tempo, Carbon plate', 'price' => '210', 'was' => null, 'rating' => 5, 'reviews' => 98, 'badge' => 'New', 'badgeVariant' => 'success'],
['name' => 'Cushion Cloud Max', 'subtitle' => 'Daily trainer, Max stack', 'price' => '132', 'was' => null, 'rating' => 4, 'reviews' => 327, 'badge' => null, 'badgeVariant' => 'secondary'],
['name' => 'Drift Stability 7', 'subtitle' => 'Support, Long run', 'price' => '118', 'was' => '140', 'rating' => 4.5, 'reviews' => 156, 'badge' => 'Sale', 'badgeVariant' => 'destructive'],
['name' => 'Featherlite Racer', 'subtitle' => 'Race day, Ultralight', 'price' => '165', 'was' => null, 'rating' => 4.5, 'reviews' => 73, 'badge' => null, 'badgeVariant' => 'secondary'],
['name' => 'Recover Glide', 'subtitle' => 'Recovery, Soft foam', 'price' => '96', 'was' => null, 'rating' => 4, 'reviews' => 189, 'badge' => 'Eco', 'badgeVariant' => 'info'],
];
// The active price chip and colour swatch, read from the `active` entries.
$activePrice = collect($prices)->first(fn ($price) => ! empty($price['active']))['label'] ?? null;
$activeColor = collect($colors)->first(fn ($color) => ! empty($color['active']))['label'] ?? null;
$count ??= count($products);
@endphp
<section {{ $attributes->merge(['class' => 'bg-background py-10 text-foreground sm:py-14']) }}>
<div class="mx-auto max-w-6xl px-4">
{{-- Category banner / header --}}
<x-ui.card class="p-6 sm:p-8">
<nav aria-label="{{ __('Breadcrumb') }}" class="text-sm text-muted-foreground">
<ol class="flex flex-wrap items-center gap-2">
@foreach ($breadcrumb as $crumb)
@php $isLast = $loop->last; @endphp
<li class="flex items-center gap-2">
@unless ($loop->first)
<svg class="size-4 shrink-0 text-muted-foreground rtl:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
@endunless
@if ($isLast)
<span class="font-medium text-foreground" aria-current="page">{{ __($crumb) }}</span>
@else
<a href="#" class="rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{{ __($crumb) }}</a>
@endif
</li>
@endforeach
</ol>
</nav>
<div class="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ __($title) }}</h1>
<p class="mt-2 text-sm text-muted-foreground">
{{ trans_choice('{0}No products|{1}:count product|[2,*]:count products', $count, ['count' => $count]) }}
</p>
</div>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="shrink-0">{{ __('Sort by') }}</span>
<x-ui.select size="sm" name="sort" class="w-48">
<option>{{ __('Most popular') }}</option>
<option>{{ __('Newest') }}</option>
<option>{{ __('Price: low to high') }}</option>
<option>{{ __('Price: high to low') }}</option>
</x-ui.select>
</label>
</div>
</x-ui.card>
<div class="mt-8 flex flex-col gap-8 lg:flex-row">
{{-- Filter sidebar --}}
<aside class="lg:w-64 lg:shrink-0">
<x-ui.card as="details" class="group" open>
<summary class="flex cursor-pointer list-none items-center justify-between p-6 lg:cursor-default lg:pb-4">
<span class="flex items-center gap-2 text-sm font-semibold">
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" />
</svg>
{{ __('Filters') }}
</span>
<svg class="size-4 text-muted-foreground transition-transform group-open:rotate-180 lg:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div class="px-6 pb-6 lg:pt-2">
{{-- Category checkboxes --}}
<fieldset>
<legend class="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Category') }}</legend>
<div class="flex flex-col gap-4">
@foreach ($categories as $i => $category)
<label class="flex cursor-pointer items-center gap-2 text-sm">
<x-ui.checkbox :checked="$category['checked'] ?? false" :name="'category[]'" :value="$category['label']" />
<span class="flex-1 text-foreground">{{ __($category['label']) }}</span>
<span class="text-sm tabular-nums text-muted-foreground">{{ $category['count'] }}</span>
</label>
@endforeach
</div>
</fieldset>
<x-ui.separator class="my-6" />
{{-- Price range chips --}}
<fieldset>
<legend class="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Price') }}</legend>
<x-ui.option-group
name="price"
size="sm"
:label="__('Price')"
:options="collect($prices)->map(fn ($price) => ['value' => $price['label'], 'label' => __($price['label'])])->all()"
:selected="$activePrice"
/>
</fieldset>
<x-ui.separator class="my-6" />
{{-- Colour swatches --}}
<fieldset>
<legend class="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Colour') }}</legend>
<x-ui.color-swatch name="colour" size="sm" :label="__('Colour')" :colors="$colors" :selected="$activeColor" />
</fieldset>
<x-ui.separator class="my-6" />
<x-ui.button variant="outline" size="sm" class="w-full">{{ __('Clear all') }}</x-ui.button>
</div>
</x-ui.card>
</aside>
{{-- Product grid --}}
<div class="min-w-0 flex-1">
<ul role="list" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($products as $product)
@php
// `subtitle` / `was` are this block's data keys; the card reads
// `description` / `compareAt`, so map them here.
$card = [
'name' => $product['name'],
'href' => $product['href'] ?? '#',
'description' => $product['subtitle'] ?? $product['description'] ?? null,
'price' => $product['price'],
'compareAt' => $product['was'] ?? $product['compareAt'] ?? null,
'rating' => $product['rating'] ?? null,
'reviews' => $product['reviews'] ?? null,
'badge' => $product['badge'] ?? null,
'badgeVariant' => $product['badgeVariant'] ?? 'default',
'gradient' => 'from-muted to-accent',
];
@endphp
<li>
<x-ui.product-card :product="$card" :currency="$currency" />
</li>
@endforeach
</ul>
{{-- Load more --}}
<div class="mt-10 flex justify-center">
<x-ui.button variant="outline">{{ __('Load more products') }}</x-ui.button>
</div>
</div>
</div>
</div>
</section>
Installation
php artisan ui:add blocks/product-category-filtered
Registry contract
Install confidence
php artisan ui:add blocks/product-category-filtered
writes only the generated targets below. The CLI validates each file hash before writing and
prompts before replacing local changes unless --force is used.
- Version
- 1.1.1
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/product-category-filtered.blade.php |
Registry dependencies
Package dependencies
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.
# Brok UI block: Product Category (`product-category-filtered`)
An ecommerce category page with a slim banner showing breadcrumb, title and result count, a sticky filter sidebar (category checkboxes, price chips, colour swatches separated by dividers), and a responsive 2-to-3-column grid of product cards with badge, rating and an Add to cart button.
Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.
## Install
```bash
php artisan ui:add blocks/product-category-filtered
```
## Render it
```blade
<x-blocks.product-category-filtered />
```
## Props
- `title` (string, default `Running shoes`) — Category title shown in the banner and page heading.
- `count` (mixed|null, default `null`) — Result count shown beside the title; null derives it from the products array.
- `breadcrumb` (mixed|null, default `null`) — Array of breadcrumb label strings.
- `categories` (mixed|null, default `null`) — Array of {label, count, checked} sidebar category filter entries.
- `prices` (mixed|null, default `null`) — Array of {label, active} price-range filter chip entries.
- `colors` (mixed|null, default `null`) — Array of {label, class, active} colour swatch filter entries using semantic token classes.
- `products` (mixed|null, default `null`) — Array of product entries rendered in the grid.
- `currency` (string, default `USD`) — ISO currency code used to format product prices.
## Use when
- Building a complete ecommerce category page in one block: a breadcrumb/title/result-count banner, a sticky sidebar of category checkboxes, price chips and colour swatches, and a responsive product grid with badges, ratings and add-to-cart buttons.
- Choosing the standard commerce register (star ratings, bestseller/sale badges) rather than a pared-back editorial listing.
## Avoid when
- Needing the same faceted layout without ratings, wishlist or badges in a calmer editorial register; use product-category-boutique.
## Rules
- Render the installed block with `<x-blocks.product-category-filtered />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.
## Links
- Docs: https://brokui.dev/blocks/product-category-filtered
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/product-category-filtered.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Guidance
Use when
- Building a complete ecommerce category page in one block: a breadcrumb/title/result-count banner, a sticky sidebar of category checkboxes, price chips and colour swatches, and a responsive product grid with badges, ratings and add-to-cart buttons.
- Choosing the standard commerce register (star ratings, bestseller/sale badges) rather than a pared-back editorial listing.
Avoid when
- Needing the same faceted layout without ratings, wishlist or badges in a calmer editorial register; use product-category-boutique.
Anti-patterns
- Do not replace semantic props with conflicting utility classes.
- Do not remove labels, focus styles, or state attributes.
- Anatomy
Usage
Render the block as a component, passing data where useful:
<x-blocks.product-category-filtered />
Built from primitives
This block composes installed <brok:*> primitives and semantic design
tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by
editing CSS variables; flip the preview to RTL to confirm it mirrors.
Source
The exact, editable file ui:add writes
into your app. The preview above renders this same source — there are no preview-only blocks.
@props([
'title' => 'Running shoes',
'count' => null,
'breadcrumb' => null,
'categories' => null,
'prices' => null,
'colors' => null,
'products' => null,
'currency' => 'USD',
])
@php
$breadcrumb ??= ['Home', 'Footwear', 'Running shoes'];
$categories ??= [
['label' => 'Road running', 'count' => 42, 'checked' => true],
['label' => 'Trail running', 'count' => 28, 'checked' => false],
['label' => 'Racing flats', 'count' => 12, 'checked' => false],
['label' => 'Stability', 'count' => 19, 'checked' => false],
['label' => 'Recovery', 'count' => 7, 'checked' => false],
];
$prices ??= [
['label' => 'Under $80', 'active' => false],
['label' => '$80 – $120', 'active' => true],
['label' => '$120 – $160', 'active' => false],
['label' => '$160+', 'active' => false],
];
// Colour swatches use semantic token classes — never raw colours.
$colors ??= [
['label' => 'Primary', 'class' => 'bg-primary', 'active' => true],
['label' => 'Accent', 'class' => 'bg-accent', 'active' => false],
['label' => 'Secondary', 'class' => 'bg-secondary', 'active' => false],
['label' => 'Muted', 'class' => 'bg-muted', 'active' => false],
['label' => 'Destructive', 'class' => 'bg-destructive', 'active' => false],
['label' => 'Foreground', 'class' => 'bg-foreground', 'active' => false],
];
$products ??= [
['name' => 'Velocity Trail GTX', 'subtitle' => 'All-terrain, Waterproof', 'price' => '148', 'was' => '180', 'rating' => 4.5, 'reviews' => 214, 'badge' => 'Bestseller', 'badgeVariant' => 'default'],
['name' => 'Aero Pace 2', 'subtitle' => 'Tempo, Carbon plate', 'price' => '210', 'was' => null, 'rating' => 5, 'reviews' => 98, 'badge' => 'New', 'badgeVariant' => 'success'],
['name' => 'Cushion Cloud Max', 'subtitle' => 'Daily trainer, Max stack', 'price' => '132', 'was' => null, 'rating' => 4, 'reviews' => 327, 'badge' => null, 'badgeVariant' => 'secondary'],
['name' => 'Drift Stability 7', 'subtitle' => 'Support, Long run', 'price' => '118', 'was' => '140', 'rating' => 4.5, 'reviews' => 156, 'badge' => 'Sale', 'badgeVariant' => 'destructive'],
['name' => 'Featherlite Racer', 'subtitle' => 'Race day, Ultralight', 'price' => '165', 'was' => null, 'rating' => 4.5, 'reviews' => 73, 'badge' => null, 'badgeVariant' => 'secondary'],
['name' => 'Recover Glide', 'subtitle' => 'Recovery, Soft foam', 'price' => '96', 'was' => null, 'rating' => 4, 'reviews' => 189, 'badge' => 'Eco', 'badgeVariant' => 'info'],
];
// The active price chip and colour swatch, read from the `active` entries.
$activePrice = collect($prices)->first(fn ($price) => ! empty($price['active']))['label'] ?? null;
$activeColor = collect($colors)->first(fn ($color) => ! empty($color['active']))['label'] ?? null;
$count ??= count($products);
@endphp
<section {{ $attributes->merge(['class' => 'bg-background py-10 text-foreground sm:py-14']) }}>
<div class="mx-auto max-w-6xl px-4">
{{-- Category banner / header --}}
<x-ui.card class="p-6 sm:p-8">
<nav aria-label="{{ __('Breadcrumb') }}" class="text-sm text-muted-foreground">
<ol class="flex flex-wrap items-center gap-2">
@foreach ($breadcrumb as $crumb)
@php $isLast = $loop->last; @endphp
<li class="flex items-center gap-2">
@unless ($loop->first)
<svg class="size-4 shrink-0 text-muted-foreground rtl:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
@endunless
@if ($isLast)
<span class="font-medium text-foreground" aria-current="page">{{ __($crumb) }}</span>
@else
<a href="#" class="rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{{ __($crumb) }}</a>
@endif
</li>
@endforeach
</ol>
</nav>
<div class="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ __($title) }}</h1>
<p class="mt-2 text-sm text-muted-foreground">
{{ trans_choice('{0}No products|{1}:count product|[2,*]:count products', $count, ['count' => $count]) }}
</p>
</div>
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<span class="shrink-0">{{ __('Sort by') }}</span>
<x-ui.select size="sm" name="sort" class="w-48">
<option>{{ __('Most popular') }}</option>
<option>{{ __('Newest') }}</option>
<option>{{ __('Price: low to high') }}</option>
<option>{{ __('Price: high to low') }}</option>
</x-ui.select>
</label>
</div>
</x-ui.card>
<div class="mt-8 flex flex-col gap-8 lg:flex-row">
{{-- Filter sidebar --}}
<aside class="lg:w-64 lg:shrink-0">
<x-ui.card as="details" class="group" open>
<summary class="flex cursor-pointer list-none items-center justify-between p-6 lg:cursor-default lg:pb-4">
<span class="flex items-center gap-2 text-sm font-semibold">
<svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" />
</svg>
{{ __('Filters') }}
</span>
<svg class="size-4 text-muted-foreground transition-transform group-open:rotate-180 lg:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div class="px-6 pb-6 lg:pt-2">
{{-- Category checkboxes --}}
<fieldset>
<legend class="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Category') }}</legend>
<div class="flex flex-col gap-4">
@foreach ($categories as $i => $category)
<label class="flex cursor-pointer items-center gap-2 text-sm">
<x-ui.checkbox :checked="$category['checked'] ?? false" :name="'category[]'" :value="$category['label']" />
<span class="flex-1 text-foreground">{{ __($category['label']) }}</span>
<span class="text-sm tabular-nums text-muted-foreground">{{ $category['count'] }}</span>
</label>
@endforeach
</div>
</fieldset>
<x-ui.separator class="my-6" />
{{-- Price range chips --}}
<fieldset>
<legend class="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Price') }}</legend>
<x-ui.option-group
name="price"
size="sm"
:label="__('Price')"
:options="collect($prices)->map(fn ($price) => ['value' => $price['label'], 'label' => __($price['label'])])->all()"
:selected="$activePrice"
/>
</fieldset>
<x-ui.separator class="my-6" />
{{-- Colour swatches --}}
<fieldset>
<legend class="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">{{ __('Colour') }}</legend>
<x-ui.color-swatch name="colour" size="sm" :label="__('Colour')" :colors="$colors" :selected="$activeColor" />
</fieldset>
<x-ui.separator class="my-6" />
<x-ui.button variant="outline" size="sm" class="w-full">{{ __('Clear all') }}</x-ui.button>
</div>
</x-ui.card>
</aside>
{{-- Product grid --}}
<div class="min-w-0 flex-1">
<ul role="list" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($products as $product)
@php
// `subtitle` / `was` are this block's data keys; the card reads
// `description` / `compareAt`, so map them here.
$card = [
'name' => $product['name'],
'href' => $product['href'] ?? '#',
'description' => $product['subtitle'] ?? $product['description'] ?? null,
'price' => $product['price'],
'compareAt' => $product['was'] ?? $product['compareAt'] ?? null,
'rating' => $product['rating'] ?? null,
'reviews' => $product['reviews'] ?? null,
'badge' => $product['badge'] ?? null,
'badgeVariant' => $product['badgeVariant'] ?? 'default',
'gradient' => 'from-muted to-accent',
];
@endphp
<li>
<x-ui.product-card :product="$card" :currency="$currency" />
</li>
@endforeach
</ul>
{{-- Load more --}}
<div class="mt-10 flex justify-center">
<x-ui.button variant="outline">{{ __('Load more products') }}</x-ui.button>
</div>
</div>
</div>
</div>
</section>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.1 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0