Bundle Builder
Build-your-own-bundle pickers — selectable products with a running total and unlock-a-discount progress.
Preview
@props([
'heading' => 'Build your bundle',
'subheading' => 'Pick :count items and save :discount% — mix and match your perfect box.',
'bundleSize' => 4,
'discount' => 15,
'products' => null,
])
@php
$products ??= [
['id' => 'oat-bar', 'name' => 'Oat & Almond Bars', 'price' => 9.0],
['id' => 'cold-brew', 'name' => 'Cold Brew Concentrate', 'price' => 14.5],
['id' => 'trail-mix', 'name' => 'Sea Salt Trail Mix', 'price' => 7.25],
['id' => 'dark-choc', 'name' => 'Dark Chocolate Squares', 'price' => 11.0],
['id' => 'green-tea', 'name' => 'Matcha Green Tea', 'price' => 12.75],
['id' => 'fruit-chews', 'name' => 'Real Fruit Chews', 'price' => 6.5],
['id' => 'kombucha', 'name' => 'Ginger Kombucha', 'price' => 13.0],
['id' => 'protein', 'name' => 'Plant Protein Scoop', 'price' => 19.0],
];
// Translate the subheading tokens up front so the JS layer never has to.
$resolvedSubheading = __($subheading, [
'count' => (int) $bundleSize,
'discount' => (int) $discount,
]);
@endphp
<x-ui.section
data-slot="bundle-builder-01"
x-data="{
bundleSize: {{ (int) $bundleSize }},
discount: {{ (float) $discount }},
products: {{ Illuminate\Support\Js::from(array_map(fn ($p) => [
'id' => (string) $p['id'],
'name' => __($p['name']),
'price' => (float) $p['price'],
], $products)) }},
selected: {},
toggle(id) {
if (this.selected[id]) {
delete this.selected[id];
} else if (this.count < this.bundleSize) {
this.selected[id] = true;
}
},
isSelected(id) {
return !! this.selected[id];
},
isFull(id) {
return this.count >= this.bundleSize && ! this.selected[id];
},
get count() {
return Object.keys(this.selected).length;
},
get subtotal() {
return this.products
.filter((p) => this.selected[p.id])
.reduce((sum, p) => sum + p.price, 0);
},
get savings() {
return this.subtotal * (this.discount / 100);
},
get total() {
return this.subtotal - this.savings;
},
get pct() {
return this.bundleSize > 0 ? Math.min(100, (this.count / this.bundleSize) * 100) : 0;
},
get reached() {
return this.count >= this.bundleSize;
},
money(value) {
return '$' + Number(value).toFixed(2);
},
}"
space="lg" {{ $attributes }}
>
<x-ui.container gutter="sm">
<div class="flex flex-col gap-4 text-center">
<h2 class="text-pretty text-2xl font-semibold tracking-tight sm:text-3xl">
{{ __($heading) }}
</h2>
<p class="mx-auto max-w-2xl text-pretty text-sm leading-relaxed text-muted-foreground sm:text-base">
{{ $resolvedSubheading }}
</p>
<div class="flex justify-center">
<x-ui.badge variant="secondary" class="gap-2">
<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="M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8" />
<rect width="20" height="5" x="2" y="7" rx="1" />
<path d="M12 22V7M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7ZM12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7Z" />
</svg>
{{ __('Save :discount% on every bundle', ['discount' => (int) $discount]) }}
</x-ui.badge>
</div>
</div>
<div class="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-[1fr_22rem]">
{{-- LEFT: selectable product grid --}}
<div>
<ul role="list" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<template x-for="product in products" :key="product.id">
<li>
<x-ui.card
class="flex h-full flex-col gap-4 p-4 transition-shadow"
x-bind:class="isSelected(product.id) ? 'ring-2 ring-primary' : 'ring-0'"
>
{{-- Image placeholder --}}
<div class="relative flex aspect-square items-center justify-center rounded-md bg-muted text-muted-foreground">
<svg class="size-10 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
<span
x-show="isSelected(product.id)"
x-cloak
class="absolute end-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
aria-hidden="true"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
</div>
<div class="flex flex-1 flex-col gap-2">
<h3 class="text-sm font-medium leading-tight text-foreground" x-text="product.name"></h3>
<p class="text-sm font-semibold text-foreground" x-text="money(product.price)"></p>
</div>
<x-ui.button
type="button"
variant="outline"
size="sm"
class="w-full"
x-on:click="toggle(product.id)"
x-bind:aria-pressed="isSelected(product.id) ? 'true' : 'false'"
x-bind:disabled="isFull(product.id)"
x-bind:class="isSelected(product.id) ? 'border-primary bg-primary/10 text-primary' : ''"
>
<span x-show="! isSelected(product.id)">{{ __('Add to bundle') }}</span>
<span x-show="isSelected(product.id)" x-cloak>{{ __('Remove') }}</span>
</x-ui.button>
</x-ui.card>
</li>
</template>
</ul>
</div>
{{-- RIGHT: sticky summary --}}
<aside aria-label="{{ __('Bundle summary') }}">
<div class="sticky top-6">
<x-ui.card class="flex flex-col gap-6 p-6">
<div class="flex flex-col gap-2">
<div class="flex items-baseline justify-between gap-2">
<h3 class="text-base font-semibold tracking-tight text-foreground">
{{ __('Your bundle') }}
</h3>
<span class="text-sm text-muted-foreground">
<span x-text="count"></span><span aria-hidden="true"> / </span><span class="sr-only">{{ __('of') }}</span><span x-text="bundleSize"></span>
</span>
</div>
{{-- The progress primitive supplies the accessible track; the
Alpine fill overlays it so the width can track selection live
(a spliced width class would be invisible to the JIT). --}}
<div class="relative">
<x-ui.progress
:value="0"
x-bind:aria-valuenow="count"
x-bind:aria-valuemax="bundleSize"
:label="__('Bundle progress')"
/>
<div
data-slot="bundle-builder-01-fill"
class="pointer-events-none absolute inset-y-0 start-0 rounded-full bg-primary transition-[width] duration-300 ease-out"
x-bind:style="'width: ' + pct + '%'"
aria-hidden="true"
></div>
</div>
<p class="text-sm text-muted-foreground" aria-live="polite">
<span x-show="! reached">
<span x-text="bundleSize - count"></span> {{ __('more to unlock your discount') }}
</span>
<span x-show="reached" x-cloak class="font-medium text-primary">
{{ __('Bundle complete — discount applied!') }}
</span>
</p>
</div>
<dl class="flex flex-col gap-2 text-sm">
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">{{ __('Items') }}</dt>
<dd class="font-medium text-foreground" x-text="count"></dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">{{ __('Subtotal') }}</dt>
<dd class="font-medium text-foreground" x-text="money(subtotal)"></dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">
{{ __('Bundle discount') }}
<span class="text-muted-foreground">(<span x-text="discount"></span>%)</span>
</dt>
<dd class="font-medium text-primary" x-text="'-' + money(savings)"></dd>
</div>
<div class="mt-2 flex items-center justify-between gap-2 border-t border-border pt-4">
<dt class="text-base font-semibold text-foreground">{{ __('Total') }}</dt>
<dd class="text-base font-semibold text-foreground" x-text="money(total)"></dd>
</div>
</dl>
<x-ui.button
type="button"
size="lg"
class="w-full"
x-bind:disabled="! reached"
x-bind:aria-disabled="! reached ? 'true' : 'false'"
>
<span x-show="reached">{{ __('Add bundle to cart') }}</span>
<span x-show="! reached" x-cloak>
{{ __('Add') }} <span x-text="bundleSize - count"></span> {{ __('more') }}
</span>
</x-ui.button>
<p class="text-pretty text-center text-sm text-muted-foreground">
{{ __('Free shipping on every bundle. Swap items anytime before checkout.') }}
</p>
</x-ui.card>
</div>
</aside>
</div>
</x-ui.container>
</x-ui.section>
Installation
php artisan ui:add blocks/bundle-builder-box
Registry contract
Install confidence
php artisan ui:add blocks/bundle-builder-box
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.0.4
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/bundle-builder-box.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: Bundle Builder (`bundle-builder-box`)
An interactive build-a-box bundle builder with a responsive grid of selectable product cards on the left (image placeholder, title, price, and an add/remove toggle) and a sticky summary aside on the right showing a progress bar toward the target bundle size, a running item count, subtotal, discount, and total, plus an add-to-cart button that stays disabled until the minimum is reached.
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/bundle-builder-box
```
## Render it
```blade
<x-blocks.bundle-builder-box />
```
## Props
- `heading` (string, default `Build your bundle`)
- `subheading` (string, default `Pick :count items and save :discount% — mix and match your perfect box.`)
- `bundle-size` (int, default `4`)
- `discount` (int, default `15`)
- `products` (mixed|null, default `null`)
## Use when
- Letting a shopper build a fixed-size product bundle by selecting items from a grid until the target count is reached.
- Showing a sticky summary with a progress bar, running subtotal, discount and total that update as items are toggled.
## Avoid when
- Use compare-products or frequently-bought-together for a fixed, non-configurable product grouping instead of a build-your-own bundle.
## Rules
- Render the installed block with `<x-blocks.bundle-builder-box />` 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/bundle-builder-box
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/bundle-builder-box.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
- Letting a shopper build a fixed-size product bundle by selecting items from a grid until the target count is reached.
- Showing a sticky summary with a progress bar, running subtotal, discount and total that update as items are toggled.
Avoid when
- Use compare-products or frequently-bought-together for a fixed, non-configurable product grouping instead of a build-your-own bundle.
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.bundle-builder-box />
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([
'heading' => 'Build your bundle',
'subheading' => 'Pick :count items and save :discount% — mix and match your perfect box.',
'bundleSize' => 4,
'discount' => 15,
'products' => null,
])
@php
$products ??= [
['id' => 'oat-bar', 'name' => 'Oat & Almond Bars', 'price' => 9.0],
['id' => 'cold-brew', 'name' => 'Cold Brew Concentrate', 'price' => 14.5],
['id' => 'trail-mix', 'name' => 'Sea Salt Trail Mix', 'price' => 7.25],
['id' => 'dark-choc', 'name' => 'Dark Chocolate Squares', 'price' => 11.0],
['id' => 'green-tea', 'name' => 'Matcha Green Tea', 'price' => 12.75],
['id' => 'fruit-chews', 'name' => 'Real Fruit Chews', 'price' => 6.5],
['id' => 'kombucha', 'name' => 'Ginger Kombucha', 'price' => 13.0],
['id' => 'protein', 'name' => 'Plant Protein Scoop', 'price' => 19.0],
];
// Translate the subheading tokens up front so the JS layer never has to.
$resolvedSubheading = __($subheading, [
'count' => (int) $bundleSize,
'discount' => (int) $discount,
]);
@endphp
<x-ui.section
data-slot="bundle-builder-01"
x-data="{
bundleSize: {{ (int) $bundleSize }},
discount: {{ (float) $discount }},
products: {{ Illuminate\Support\Js::from(array_map(fn ($p) => [
'id' => (string) $p['id'],
'name' => __($p['name']),
'price' => (float) $p['price'],
], $products)) }},
selected: {},
toggle(id) {
if (this.selected[id]) {
delete this.selected[id];
} else if (this.count < this.bundleSize) {
this.selected[id] = true;
}
},
isSelected(id) {
return !! this.selected[id];
},
isFull(id) {
return this.count >= this.bundleSize && ! this.selected[id];
},
get count() {
return Object.keys(this.selected).length;
},
get subtotal() {
return this.products
.filter((p) => this.selected[p.id])
.reduce((sum, p) => sum + p.price, 0);
},
get savings() {
return this.subtotal * (this.discount / 100);
},
get total() {
return this.subtotal - this.savings;
},
get pct() {
return this.bundleSize > 0 ? Math.min(100, (this.count / this.bundleSize) * 100) : 0;
},
get reached() {
return this.count >= this.bundleSize;
},
money(value) {
return '$' + Number(value).toFixed(2);
},
}"
space="lg" {{ $attributes }}
>
<x-ui.container gutter="sm">
<div class="flex flex-col gap-4 text-center">
<h2 class="text-pretty text-2xl font-semibold tracking-tight sm:text-3xl">
{{ __($heading) }}
</h2>
<p class="mx-auto max-w-2xl text-pretty text-sm leading-relaxed text-muted-foreground sm:text-base">
{{ $resolvedSubheading }}
</p>
<div class="flex justify-center">
<x-ui.badge variant="secondary" class="gap-2">
<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="M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8" />
<rect width="20" height="5" x="2" y="7" rx="1" />
<path d="M12 22V7M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7ZM12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7Z" />
</svg>
{{ __('Save :discount% on every bundle', ['discount' => (int) $discount]) }}
</x-ui.badge>
</div>
</div>
<div class="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-[1fr_22rem]">
{{-- LEFT: selectable product grid --}}
<div>
<ul role="list" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<template x-for="product in products" :key="product.id">
<li>
<x-ui.card
class="flex h-full flex-col gap-4 p-4 transition-shadow"
x-bind:class="isSelected(product.id) ? 'ring-2 ring-primary' : 'ring-0'"
>
{{-- Image placeholder --}}
<div class="relative flex aspect-square items-center justify-center rounded-md bg-muted text-muted-foreground">
<svg class="size-10 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
<span
x-show="isSelected(product.id)"
x-cloak
class="absolute end-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground"
aria-hidden="true"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
</div>
<div class="flex flex-1 flex-col gap-2">
<h3 class="text-sm font-medium leading-tight text-foreground" x-text="product.name"></h3>
<p class="text-sm font-semibold text-foreground" x-text="money(product.price)"></p>
</div>
<x-ui.button
type="button"
variant="outline"
size="sm"
class="w-full"
x-on:click="toggle(product.id)"
x-bind:aria-pressed="isSelected(product.id) ? 'true' : 'false'"
x-bind:disabled="isFull(product.id)"
x-bind:class="isSelected(product.id) ? 'border-primary bg-primary/10 text-primary' : ''"
>
<span x-show="! isSelected(product.id)">{{ __('Add to bundle') }}</span>
<span x-show="isSelected(product.id)" x-cloak>{{ __('Remove') }}</span>
</x-ui.button>
</x-ui.card>
</li>
</template>
</ul>
</div>
{{-- RIGHT: sticky summary --}}
<aside aria-label="{{ __('Bundle summary') }}">
<div class="sticky top-6">
<x-ui.card class="flex flex-col gap-6 p-6">
<div class="flex flex-col gap-2">
<div class="flex items-baseline justify-between gap-2">
<h3 class="text-base font-semibold tracking-tight text-foreground">
{{ __('Your bundle') }}
</h3>
<span class="text-sm text-muted-foreground">
<span x-text="count"></span><span aria-hidden="true"> / </span><span class="sr-only">{{ __('of') }}</span><span x-text="bundleSize"></span>
</span>
</div>
{{-- The progress primitive supplies the accessible track; the
Alpine fill overlays it so the width can track selection live
(a spliced width class would be invisible to the JIT). --}}
<div class="relative">
<x-ui.progress
:value="0"
x-bind:aria-valuenow="count"
x-bind:aria-valuemax="bundleSize"
:label="__('Bundle progress')"
/>
<div
data-slot="bundle-builder-01-fill"
class="pointer-events-none absolute inset-y-0 start-0 rounded-full bg-primary transition-[width] duration-300 ease-out"
x-bind:style="'width: ' + pct + '%'"
aria-hidden="true"
></div>
</div>
<p class="text-sm text-muted-foreground" aria-live="polite">
<span x-show="! reached">
<span x-text="bundleSize - count"></span> {{ __('more to unlock your discount') }}
</span>
<span x-show="reached" x-cloak class="font-medium text-primary">
{{ __('Bundle complete — discount applied!') }}
</span>
</p>
</div>
<dl class="flex flex-col gap-2 text-sm">
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">{{ __('Items') }}</dt>
<dd class="font-medium text-foreground" x-text="count"></dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">{{ __('Subtotal') }}</dt>
<dd class="font-medium text-foreground" x-text="money(subtotal)"></dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt class="text-muted-foreground">
{{ __('Bundle discount') }}
<span class="text-muted-foreground">(<span x-text="discount"></span>%)</span>
</dt>
<dd class="font-medium text-primary" x-text="'-' + money(savings)"></dd>
</div>
<div class="mt-2 flex items-center justify-between gap-2 border-t border-border pt-4">
<dt class="text-base font-semibold text-foreground">{{ __('Total') }}</dt>
<dd class="text-base font-semibold text-foreground" x-text="money(total)"></dd>
</div>
</dl>
<x-ui.button
type="button"
size="lg"
class="w-full"
x-bind:disabled="! reached"
x-bind:aria-disabled="! reached ? 'true' : 'false'"
>
<span x-show="reached">{{ __('Add bundle to cart') }}</span>
<span x-show="! reached" x-cloak>
{{ __('Add') }} <span x-text="bundleSize - count"></span> {{ __('more') }}
</span>
</x-ui.button>
<p class="text-pretty text-center text-sm text-muted-foreground">
{{ __('Free shipping on every bundle. Swap items anytime before checkout.') }}
</p>
</x-ui.card>
</div>
</aside>
</div>
</x-ui.container>
</x-ui.section>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.4 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0