Spin to Win
Gamified discount wheels — an SVG prize wheel with email capture and a spin reveal.
Preview
@props([
// The prize labels, in clockwise order. 6 or 8 entries land best on the wheel.
'segments' => ['10% OFF', 'Free shipping', '5% OFF', 'Try again', '15% OFF', 'Mystery gift', '20% OFF', 'Try again'],
'heading' => 'Spin to win!',
'subline' => 'Drop your email and spin the wheel for an instant, one-time discount code.',
'placeholder' => 'you@example.com',
])
@php
// Spin-to-win discount wheel. The wheel is an inline <svg> divided into N
// equal wedges; each wedge <path> is filled with `currentColor` and tinted
// by an ALTERNATING semantic token text-colour class (text-primary /
// text-secondary / text-accent / text-muted) — never a raw colour. The
// wedge geometry (the `d` arc) is computed here in PHP so the coordinates
// are literal in the rendered markup. Interactivity is inline Alpine on the
// plain <section>; the only inline style anywhere is the rotation transform
// on the wheel <g>.
$count = max(2, count($segments));
// Wheel maths around a 200x200 viewBox (centre 100,100, radius 96).
$cx = 100;
$cy = 100;
$r = 96;
$step = 360.0 / $count;
// Alternating fill + label token classes so adjacent wedges always contrast.
$fillClasses = ['text-primary', 'text-secondary', 'text-accent', 'text-muted'];
$labelClasses = ['text-primary-foreground', 'text-secondary-foreground', 'text-accent-foreground', 'text-foreground'];
$wedges = [];
foreach ($segments as $i => $label) {
// Wedge i spans [i*step, (i+1)*step). 0deg points up (12 o'clock); we
// offset by -90deg so wedge 0 starts at the top under the pointer.
$a0 = deg2rad($i * $step - 90);
$a1 = deg2rad(($i + 1) * $step - 90);
$x0 = round($cx + $r * cos($a0), 3);
$y0 = round($cy + $r * sin($a0), 3);
$x1 = round($cx + $r * cos($a1), 3);
$y1 = round($cy + $r * sin($a1), 3);
// Mid-angle, used to place + rotate the label so it reads outward.
$mid = deg2rad(($i + 0.5) * $step - 90);
$lr = $r * 0.62;
$lx = round($cx + $lr * cos($mid), 3);
$ly = round($cy + $lr * sin($mid), 3);
$labelRot = round(($i + 0.5) * $step, 3); // text baseline follows the spoke
$wedges[] = [
'd' => "M{$cx},{$cy} L{$x0},{$y0} A{$r},{$r} 0 0,1 {$x1},{$y1} Z",
'fill' => $fillClasses[$i % count($fillClasses)],
'label' => $labelClasses[$i % count($labelClasses)],
'lx' => $lx,
'ly' => $ly,
'rot' => $labelRot,
'text' => $label,
];
}
// JSON for the Alpine segment list (used to resolve the won prize).
$segmentsJson = json_encode(array_map(fn ($s) => __($s), $segments), JSON_UNESCAPED_UNICODE);
@endphp
<section
x-data="{
angle: 0,
spinning: false,
result: null,
step: {{ $step }},
count: {{ $count }},
segments: {{ $segmentsJson }},
spin() {
if (this.spinning) return;
this.spinning = true;
this.result = null;
const index = Math.floor(Math.random() * this.count);
// Wedge `index` is centred at (index + 0.5) * step from the top.
// Rotate the wheel so that centre lands under the fixed top pointer:
// bring it back to 0 (negative), add several full turns for drama.
const turns = 5;
const offset = (index + 0.5) * this.step;
this.angle += turns * 360 + (360 - (this.angle % 360)) - offset;
setTimeout(() => {
this.result = this.segments[index];
this.spinning = false;
}, 3000);
},
}"
{{ $attributes->merge(['class' => 'flex min-h-svh items-center justify-center bg-muted/40 px-4 py-12 text-foreground']) }}
>
<x-ui.card class="relative w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-card p-0 shadow-xl">
<div class="grid grid-cols-1 sm:grid-cols-2">
{{-- LEFT: the spin wheel --}}
<div class="relative flex items-center justify-center bg-muted/30 px-6 py-10 sm:py-12">
{{-- Fixed pointer triangle at the top, pointing down into the wheel. --}}
<div class="pointer-events-none absolute top-6 z-10 text-primary" aria-hidden="true">
<svg viewBox="0 0 24 24" class="size-7 drop-shadow" fill="currentColor">
<path d="M12 20 2 6h20z" />
</svg>
</div>
<div class="relative aspect-square w-full max-w-[18rem]">
<svg viewBox="0 0 200 200" class="size-full" aria-hidden="true">
{{-- The rotating wheel group. ONLY inline style is the transform. --}}
<g
class="motion-safe:transition-transform motion-safe:duration-[3000ms] motion-safe:ease-out"
style="transform-origin: 100px 100px;"
:style="'transform: rotate(' + angle + 'deg)'"
>
@foreach ($wedges as $w)
<path class="{{ $w['fill'] }}" fill="currentColor" stroke="currentColor" stroke-width="0.5" d="{{ $w['d'] }}" />
@endforeach
@foreach ($wedges as $w)
<text
class="{{ $w['label'] }}"
fill="currentColor"
x="{{ $w['lx'] }}"
y="{{ $w['ly'] }}"
transform="rotate({{ $w['rot'] }} {{ $w['lx'] }} {{ $w['ly'] }})"
text-anchor="middle"
dominant-baseline="middle"
font-size="8"
font-weight="700"
>{{ __($w['text']) }}</text>
@endforeach
</g>
{{-- Hub --}}
<circle cx="100" cy="100" r="10" class="text-card" fill="currentColor" stroke="currentColor" stroke-width="1" />
</svg>
</div>
</div>
{{-- RIGHT: capture + spin --}}
<div class="flex flex-col gap-4 p-6 sm:p-8">
<x-ui.badge variant="secondary" class="w-fit">{{ __('Exclusive offer') }}</x-ui.badge>
<div class="flex flex-col gap-2">
<h2 class="text-pretty text-2xl font-semibold leading-tight tracking-tight sm:text-3xl">{{ __($heading) }}</h2>
<p class="text-pretty text-sm leading-relaxed text-muted-foreground">{{ __($subline) }}</p>
</div>
{{-- Capture + spin (hidden once a prize is won). --}}
<form
method="POST"
action="#"
class="flex flex-col gap-4"
x-show="!result"
@submit.prevent="spin()"
>
@csrf
<x-ui.field>
<x-ui.label for="spin-to-win-01-email">{{ __('Email address') }}</x-ui.label>
<x-ui.input
id="spin-to-win-01-email"
type="email"
name="email"
autocomplete="email"
required
:placeholder="__($placeholder)"
class="w-full"
/>
</x-ui.field>
<x-ui.button
type="submit"
size="lg"
class="w-full uppercase tracking-wide"
x-bind:disabled="spinning"
>
<span x-show="!spinning">{{ __('Spin') }}</span>
<span x-show="spinning" x-cloak>{{ __('Spinning…') }}</span>
</x-ui.button>
<p class="text-pretty text-sm leading-relaxed text-muted-foreground">
{{ __('One spin per email. Discount codes are single-use and expire in 24 hours.') }}
</p>
</form>
{{-- Result reveal --}}
{{-- The payoff matches the build-up: once the wheel settles, the panel
pops in and its lines rise in sequence (ui.css [data-animate]
keyframes restart on each x-show reveal; reduced-motion renders
everything instantly). --}}
<div
x-show="result"
x-cloak
aria-live="polite"
data-animate="pop"
class="flex flex-col items-start gap-4 rounded-lg border border-border bg-muted/40 p-6"
>
<p data-animate="rise" data-animate-order="1" class="text-sm font-medium text-muted-foreground">{{ __('Congratulations') }}</p>
<p data-animate="rise" data-animate-order="2" class="text-pretty text-xl font-bold leading-tight">
{{ __('You won') }} <span x-text="result"></span>{{ __('!') }}
</p>
<div data-animate="rise" data-animate-order="3" class="flex items-center gap-2 rounded-md border border-dashed border-input bg-background px-4 py-2">
<span class="text-sm uppercase tracking-wide text-muted-foreground">{{ __('Code') }}</span>
<span class="font-mono text-sm font-semibold tracking-widest">SPIN-WIN</span>
</div>
<x-ui.button data-animate="rise" data-animate-order="4" size="lg" class="w-full">{{ __('Claim my prize') }}</x-ui.button>
</div>
</div>
</div>
</x-ui.card>
</section>
Installation
php artisan ui:add blocks/spin-to-win-wheel
Registry contract
Install confidence
php artisan ui:add blocks/spin-to-win-wheel
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.4
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/spin-to-win-wheel.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: Spin To Win (`spin-to-win-wheel`)
A centered spin-to-win discount promo in a two-pane card: the left pane is an inline-SVG prize wheel of equal token-coloured wedges with a fixed top pointer that spins on demand, and the right pane has a heading, subline, a required email capture field, and a SPIN button that disables while spinning and reveals the won prize with a single-use code and a claim 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/spin-to-win-wheel
```
## Render it
```blade
<x-blocks.spin-to-win-wheel />
```
## Props
- `segments` (array, default `['10% OFF', 'Free shipping', '5% OFF', 'Try again', '15% OFF', 'Mystery gift', '20% OFF', 'Try again']`) — Prize labels in clockwise order around the wheel.
- `heading` (string, default `Spin to win!`) — Section heading.
- `subline` (string, default `Drop your email and spin the wheel for an instant, one-time discount code.`) — Supporting copy under the heading.
- `placeholder` (string, default `[email protected]`) — Email input placeholder text.
## Use when
- Capturing an email address in exchange for a gamified spin-the-wheel discount reveal.
- Wanting a playful incentive over a plain form in a promotional pop-up or landing section.
## Avoid when
- Needing a plain, non-gamified email capture; use the waitlist block instead.
## Rules
- Render the installed block with `<x-blocks.spin-to-win-wheel />` 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/spin-to-win-wheel
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/spin-to-win-wheel.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
- Capturing an email address in exchange for a gamified spin-the-wheel discount reveal.
- Wanting a playful incentive over a plain form in a promotional pop-up or landing section.
Avoid when
- Needing a plain, non-gamified email capture; use the waitlist block instead.
Anti-patterns
- Do not replace semantic props with conflicting utility classes.
- Do not remove labels, focus styles, or state attributes.
Usage
Render the block as a component, passing data where useful:
<x-blocks.spin-to-win-wheel />
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([
// The prize labels, in clockwise order. 6 or 8 entries land best on the wheel.
'segments' => ['10% OFF', 'Free shipping', '5% OFF', 'Try again', '15% OFF', 'Mystery gift', '20% OFF', 'Try again'],
'heading' => 'Spin to win!',
'subline' => 'Drop your email and spin the wheel for an instant, one-time discount code.',
'placeholder' => 'you@example.com',
])
@php
// Spin-to-win discount wheel. The wheel is an inline <svg> divided into N
// equal wedges; each wedge <path> is filled with `currentColor` and tinted
// by an ALTERNATING semantic token text-colour class (text-primary /
// text-secondary / text-accent / text-muted) — never a raw colour. The
// wedge geometry (the `d` arc) is computed here in PHP so the coordinates
// are literal in the rendered markup. Interactivity is inline Alpine on the
// plain <section>; the only inline style anywhere is the rotation transform
// on the wheel <g>.
$count = max(2, count($segments));
// Wheel maths around a 200x200 viewBox (centre 100,100, radius 96).
$cx = 100;
$cy = 100;
$r = 96;
$step = 360.0 / $count;
// Alternating fill + label token classes so adjacent wedges always contrast.
$fillClasses = ['text-primary', 'text-secondary', 'text-accent', 'text-muted'];
$labelClasses = ['text-primary-foreground', 'text-secondary-foreground', 'text-accent-foreground', 'text-foreground'];
$wedges = [];
foreach ($segments as $i => $label) {
// Wedge i spans [i*step, (i+1)*step). 0deg points up (12 o'clock); we
// offset by -90deg so wedge 0 starts at the top under the pointer.
$a0 = deg2rad($i * $step - 90);
$a1 = deg2rad(($i + 1) * $step - 90);
$x0 = round($cx + $r * cos($a0), 3);
$y0 = round($cy + $r * sin($a0), 3);
$x1 = round($cx + $r * cos($a1), 3);
$y1 = round($cy + $r * sin($a1), 3);
// Mid-angle, used to place + rotate the label so it reads outward.
$mid = deg2rad(($i + 0.5) * $step - 90);
$lr = $r * 0.62;
$lx = round($cx + $lr * cos($mid), 3);
$ly = round($cy + $lr * sin($mid), 3);
$labelRot = round(($i + 0.5) * $step, 3); // text baseline follows the spoke
$wedges[] = [
'd' => "M{$cx},{$cy} L{$x0},{$y0} A{$r},{$r} 0 0,1 {$x1},{$y1} Z",
'fill' => $fillClasses[$i % count($fillClasses)],
'label' => $labelClasses[$i % count($labelClasses)],
'lx' => $lx,
'ly' => $ly,
'rot' => $labelRot,
'text' => $label,
];
}
// JSON for the Alpine segment list (used to resolve the won prize).
$segmentsJson = json_encode(array_map(fn ($s) => __($s), $segments), JSON_UNESCAPED_UNICODE);
@endphp
<section
x-data="{
angle: 0,
spinning: false,
result: null,
step: {{ $step }},
count: {{ $count }},
segments: {{ $segmentsJson }},
spin() {
if (this.spinning) return;
this.spinning = true;
this.result = null;
const index = Math.floor(Math.random() * this.count);
// Wedge `index` is centred at (index + 0.5) * step from the top.
// Rotate the wheel so that centre lands under the fixed top pointer:
// bring it back to 0 (negative), add several full turns for drama.
const turns = 5;
const offset = (index + 0.5) * this.step;
this.angle += turns * 360 + (360 - (this.angle % 360)) - offset;
setTimeout(() => {
this.result = this.segments[index];
this.spinning = false;
}, 3000);
},
}"
{{ $attributes->merge(['class' => 'flex min-h-svh items-center justify-center bg-muted/40 px-4 py-12 text-foreground']) }}
>
<x-ui.card class="relative w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-card p-0 shadow-xl">
<div class="grid grid-cols-1 sm:grid-cols-2">
{{-- LEFT: the spin wheel --}}
<div class="relative flex items-center justify-center bg-muted/30 px-6 py-10 sm:py-12">
{{-- Fixed pointer triangle at the top, pointing down into the wheel. --}}
<div class="pointer-events-none absolute top-6 z-10 text-primary" aria-hidden="true">
<svg viewBox="0 0 24 24" class="size-7 drop-shadow" fill="currentColor">
<path d="M12 20 2 6h20z" />
</svg>
</div>
<div class="relative aspect-square w-full max-w-[18rem]">
<svg viewBox="0 0 200 200" class="size-full" aria-hidden="true">
{{-- The rotating wheel group. ONLY inline style is the transform. --}}
<g
class="motion-safe:transition-transform motion-safe:duration-[3000ms] motion-safe:ease-out"
style="transform-origin: 100px 100px;"
:style="'transform: rotate(' + angle + 'deg)'"
>
@foreach ($wedges as $w)
<path class="{{ $w['fill'] }}" fill="currentColor" stroke="currentColor" stroke-width="0.5" d="{{ $w['d'] }}" />
@endforeach
@foreach ($wedges as $w)
<text
class="{{ $w['label'] }}"
fill="currentColor"
x="{{ $w['lx'] }}"
y="{{ $w['ly'] }}"
transform="rotate({{ $w['rot'] }} {{ $w['lx'] }} {{ $w['ly'] }})"
text-anchor="middle"
dominant-baseline="middle"
font-size="8"
font-weight="700"
>{{ __($w['text']) }}</text>
@endforeach
</g>
{{-- Hub --}}
<circle cx="100" cy="100" r="10" class="text-card" fill="currentColor" stroke="currentColor" stroke-width="1" />
</svg>
</div>
</div>
{{-- RIGHT: capture + spin --}}
<div class="flex flex-col gap-4 p-6 sm:p-8">
<x-ui.badge variant="secondary" class="w-fit">{{ __('Exclusive offer') }}</x-ui.badge>
<div class="flex flex-col gap-2">
<h2 class="text-pretty text-2xl font-semibold leading-tight tracking-tight sm:text-3xl">{{ __($heading) }}</h2>
<p class="text-pretty text-sm leading-relaxed text-muted-foreground">{{ __($subline) }}</p>
</div>
{{-- Capture + spin (hidden once a prize is won). --}}
<form
method="POST"
action="#"
class="flex flex-col gap-4"
x-show="!result"
@submit.prevent="spin()"
>
@csrf
<x-ui.field>
<x-ui.label for="spin-to-win-01-email">{{ __('Email address') }}</x-ui.label>
<x-ui.input
id="spin-to-win-01-email"
type="email"
name="email"
autocomplete="email"
required
:placeholder="__($placeholder)"
class="w-full"
/>
</x-ui.field>
<x-ui.button
type="submit"
size="lg"
class="w-full uppercase tracking-wide"
x-bind:disabled="spinning"
>
<span x-show="!spinning">{{ __('Spin') }}</span>
<span x-show="spinning" x-cloak>{{ __('Spinning…') }}</span>
</x-ui.button>
<p class="text-pretty text-sm leading-relaxed text-muted-foreground">
{{ __('One spin per email. Discount codes are single-use and expire in 24 hours.') }}
</p>
</form>
{{-- Result reveal --}}
{{-- The payoff matches the build-up: once the wheel settles, the panel
pops in and its lines rise in sequence (ui.css [data-animate]
keyframes restart on each x-show reveal; reduced-motion renders
everything instantly). --}}
<div
x-show="result"
x-cloak
aria-live="polite"
data-animate="pop"
class="flex flex-col items-start gap-4 rounded-lg border border-border bg-muted/40 p-6"
>
<p data-animate="rise" data-animate-order="1" class="text-sm font-medium text-muted-foreground">{{ __('Congratulations') }}</p>
<p data-animate="rise" data-animate-order="2" class="text-pretty text-xl font-bold leading-tight">
{{ __('You won') }} <span x-text="result"></span>{{ __('!') }}
</p>
<div data-animate="rise" data-animate-order="3" class="flex items-center gap-2 rounded-md border border-dashed border-input bg-background px-4 py-2">
<span class="text-sm uppercase tracking-wide text-muted-foreground">{{ __('Code') }}</span>
<span class="font-mono text-sm font-semibold tracking-widest">SPIN-WIN</span>
</div>
<x-ui.button data-animate="rise" data-animate-order="4" size="lg" class="w-full">{{ __('Claim my prize') }}</x-ui.button>
</div>
</div>
</div>
</x-ui.card>
</section>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.4 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0