Skip to content
Brok UI

Loading…

No results

Spin to Win

Open source

Gamified discount wheels — an SVG prize wheel with email capture and a spin reveal.

Preview

resources/views/blocks/spin-to-win-wheel.blade.php Blade
@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

terminal
php artisan ui:add blocks/spin-to-win-wheel

Installs these dependencies automatically:

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

card button badge input field label

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.

spin-to-win-wheel.md
# 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:

resources/views/welcome.blade.php Blade
<x-blocks.spin-to-win-wheel />

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/spin-to-win-wheel.blade.php Blade
@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