Skip to content
Brok UI

Loading…

No results

Product Category

Open source

Category pages — a banner with a faceted filter sidebar (categories, price, swatches) and a product grid.

Preview

resources/views/blocks/product-category-filtered.blade.php Blade
@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

terminal
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

product-card select option-group color-swatch card checkbox separator button

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.

product-category-filtered.md
# 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
category banner breadcrumb title result count filter sidebar category checkboxes price chips colour swatches product grid product card

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.product-category-filtered />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

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.

resources/views/blocks/product-category-filtered.blade.php Blade
@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