Skip to content
Brok UI

Loading…

No results

Select

Open source Core

Pick a value from a list — the native select, a custom listbox, a filterable combobox or a scrolling wheel.

Version
v1.1.1
Stability
stable
License
MIT
Related
Native Select
Advanced Select
Combobox

Wheel Picker — An iOS-style 3D wheel picker: a scrollable cylinder of options that rotates, snaps to the centred selection, and feeds a hidden input.

Preview

previews.components.wheel-picker.default.blade.php Blade
<div class="flex min-h-56 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
    <x-ui.wheel-picker
        :options="['January','February','March','April','May','June','July','August','September','October','November','December']"
        value="June"
        class="w-40"
    />
</div>

Installation

terminal
php artisan ui:add wheel-picker

Note

This component ships an Alpine behavior module at resources/js/ui/wheel-picker.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './wheel-picker.js';

Registry contract

php artisan ui:add wheel-picker writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/wheel-picker.blade.php
  • js resources/js/ui/wheel-picker.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

wheel-picker.md
# Brok UI: Wheel Picker (`wheel-picker`)

An iOS-style 3D wheel picker: a scrollable cylinder of options that rotates, snaps to the centred selection, and feeds a hidden input.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add wheel-picker
```

## Usage

```blade
<div class="flex min-h-56 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
    <x-ui.wheel-picker
        :options="['January','February','March','April','May','June','July','August','September','October','November','December']"
        value="June"
        class="w-40"
    />
</div>
```

## Props

- `options` (mixed|null, default `null`) — A list of strings, or a value => label map; falls back to the twelve months when omitted.
- `value` (mixed|null, default `null`) — Preselected value; defaults to the first option.
- `name` (mixed|null, default `null`) — Form field name for the hidden input that carries the selected value.
- `visibleCount` (int, default `5`) — Number of rows visible in the viewport; forced to the next odd number so one row sits dead-centre.
- `itemHeight` (int, default `36`) — Height in pixels of each option row, and of the centred highlight band.

## Use when

- Use when users must enter freeform information that cannot be reliably selected from a list.
- Picking one value from a short ordered list (months, hours, quantities) with a native-feeling scroll-and-snap gesture.
- Building a mobile-style date/time or quantity picker where drag or wheel input feels more natural than a dropdown.

## Avoid when

- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The list is long or needs filtering by typing; use combobox or select-advanced instead.
- The value has no inherent order to scroll through; use select-advanced instead.

## Anti-patterns

- Using freeform entry for a bounded answer set

## Rules

- Use the `<brok:wheel-picker>` tag (or `<x-ui.wheel-picker>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/wheel-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/wheel-picker.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
options mixed | null null A list of strings, or a value => label map; falls back to the twelve months when omitted.
value mixed | null null Preselected value; defaults to the first option.
name mixed | null null Form field name for the hidden input that carries the selected value.
visibleCount int 5 Number of rows visible in the viewport; forced to the next odd number so one row sits dead-centre.
itemHeight int 36 Height in pixels of each option row, and of the centred highlight band.

Slots

  • default — Unused; options render from the options prop only.

Data slots

Stable hooks for CSS overrides and browser tests.

wheel-picker wheel-picker-band wheel-picker-list wheel-picker-option wheel-picker-viewport

Behavior

  • Drag, wheel/trackpad scroll, or arrow keys rotate the cylinder of options and snap to the nearest one at rest.
  • The centred row is the active selection; its value mirrors into a hidden input when name is set.
  • Options are laid out with a 3D perspective transform that curves them away from the centre band.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Freeform text input

Collect unpredictable freeform information.

Use when

  • Use when users must enter freeform information that cannot be reliably selected from a list.
  • Picking one value from a short ordered list (months, hours, quantities) with a native-feeling scroll-and-snap gesture.
  • Building a mobile-style date/time or quantity picker where drag or wheel input feels more natural than a dropdown.

Avoid when

  • Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
  • The list is long or needs filtering by typing; use combobox or select-advanced instead.
  • The value has no inherent order to scroll through; use select-advanced instead.

Use instead

  • Radio or checkbox for bounded choices
  • Select or combobox for known options

Anti-patterns

  • Using freeform entry for a bounded answer set
Anatomy
root wheel-picker-viewport wheel-picker-band wheel-picker-list wheel-picker-option
Theming hooks
wheel-picker wheel-picker-band wheel-picker-list wheel-picker-option wheel-picker-viewport

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowUp ArrowDown ArrowLeft ArrowRight Home End
Focus
managed
  • The viewport carries role="listbox" and is keyboard-focusable, so arrow keys move the selection without a pointer.
  • The active option is exposed through aria-activedescendant and aria-selected, not only through its visual position.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="wheel-picker-{{ $record->id }}">
    <div class="flex min-h-56 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
        <x-ui.wheel-picker
            :options="['January','February','March','April','May','June','July','August','September','October','November','December']"
            value="June"
            class="w-40"
        />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:wheel-picker
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/wheel-picker.blade.php Blade
@props([
    'options'      => null,
    'value'        => null,
    'name'         => null,
    'visibleCount' => 5,
    'itemHeight'   => 36,
])
@php
    // Normalize options into an ordered list of [value, label] pairs. Accept a
    // list of strings (value === label) or an associative value => label map.
    // Fall back to the twelve months so the preview animates without config.
    $source = $options;
    if (! is_array($source) || count($source) === 0) {
        $source = [
            __('January'), __('February'), __('March'), __('April'),
            __('May'), __('June'), __('July'), __('August'),
            __('September'), __('October'), __('November'), __('December'),
        ];
    }

    $isList = array_keys($source) === range(0, count($source) - 1);
    $pairs = [];
    foreach ($source as $key => $label) {
        $pairs[] = $isList
            ? ['value' => (string) $label, 'label' => (string) $label]
            : ['value' => (string) $key, 'label' => (string) $label];
    }

    // visibleCount must be odd so a single row sits dead-centre.
    $visibleCount = max(1, (int) $visibleCount);
    if ($visibleCount % 2 === 0) {
        $visibleCount++;
    }
    $itemHeight = max(16, (int) $itemHeight);

    $selectedValue = $value !== null ? (string) $value : ($pairs[0]['value'] ?? '');

    $viewportHeight = $visibleCount * $itemHeight;
    $listboxId = 'wheel-picker-' . \Illuminate\Support\Str::random(8);
@endphp
<div
    x-data="uiWheelPicker({ options: @js($pairs), value: @js($selectedValue), itemHeight: @js((int) $itemHeight), visibleCount: @js((int) $visibleCount) })"
    {{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
         the same x-model contract) attach to the root. Inert without either. --}}
    x-modelable="model"
    data-slot="wheel-picker"
    {{ $attributes->merge(['class' => 'relative inline-flex flex-col']) }}
>
    {{-- The listbox / drag surface. role + keyboard live here. --}}
    <div
        x-ref="viewport"
        data-slot="wheel-picker-viewport"
        role="listbox"
        tabindex="0"
        aria-label="{{ __('Wheel picker') }}"
        :aria-activedescendant="`{{ $listboxId }}-option-${index}`"
        @pointerdown="startDrag($event)"
        @wheel="onWheel($event)"
        @keydown="onKey($event)"
        style="height: {{ $viewportHeight }}px; perspective: 1000px;"
        class="relative w-full cursor-grab touch-none select-none overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md"
    >
        {{-- Highlighted centre band marking the active selection line. --}}
        <div
            data-slot="wheel-picker-band"
            aria-hidden="true"
            class="pointer-events-none absolute inset-x-0 z-10 border-y border-border bg-muted/40"
            style="top: 50%; height: {{ $itemHeight }}px; transform: translateY(-50%);"
        ></div>

        {{-- The rolling cylinder of options. --}}
        <div
            data-slot="wheel-picker-list"
            class="absolute inset-x-0 top-1/2"
            style="transform-style: preserve-3d;"
        >
            <template x-for="(option, i) in options" :key="i">
                <div
                    data-slot="wheel-picker-option"
                    role="option"
                    :id="`{{ $listboxId }}-option-${i}`"
                    :data-index="i"
                    :aria-selected="i === index ? 'true' : 'false'"
                    @click="selectIndex(i)"
                    class="absolute inset-x-0 flex items-center justify-center text-sm font-medium will-change-transform"
                    :class="{ 'text-foreground': i === index, 'text-muted-foreground': i !== index, 'transition-[transform,opacity] duration-200 ease-out': transitioning, 'motion-reduce:transition-none': true }"
                    :style="itemStyle(i)"
                    x-text="option.label"
                ></div>
            </template>
        </div>
    </div>

    @if ($name)
        <input type="hidden" name="{{ $name }}" :value="selectedValue" />
    @endif
</div>
resources/js/ui/wheel-picker.js JS
/**
 * Wheel Picker behavior — an iOS-style 3D rotating selection wheel.
 *
 * A continuous `offset` (in px) tracks how far the cylinder has scrolled; the
 * selected index is `round(-offset / itemHeight)` clamped to the option range.
 * Pointer drag (pointerdown on the viewport, pointermove/up bound on `document`
 * and removed on release), mouse `wheel`, and the keyboard (ArrowUp/Down for
 * prev/next, Home/End for first/last) all move the offset; on release the offset
 * SNAPS to the nearest item under a CSS transition toggled by a `transitioning`
 * flag.
 *
 * Each option is positioned on a virtual cylinder: its distance (in item slots)
 * from the centre line drives a `rotateX` and a `translateZ(radius)`, plus an
 * opacity fade toward the edges. Under `prefers-reduced-motion: reduce` the wheel
 * renders FLAT (a plain translated list, no rotateX/translateZ) but still snaps.
 *
 * On every selection change the root dispatches a bubbling `change` CustomEvent
 * with `{ detail: { value } }`, and the hidden input (bound to `selectedValue`)
 * updates so plain form posts pick up the choice.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiWheelPicker', (config = {}) => ({
        options: Array.isArray(config.options) ? config.options : [],
        itemHeight: config.itemHeight ?? 36,
        visibleCount: config.visibleCount ?? 5,

        /** Continuous scroll offset in px (0 = first item centred). */
        offset: 0,
        /** Currently selected option index. */
        index: 0,
        /** When true, the cylinder animates between offsets (snap). */
        transitioning: false,

        _reduced: false,
        _moveHandler: null,
        _upHandler: null,
        _lastClientY: 0,
        _radius: 0,

        init() {
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            // Cylinder radius: half the chord spanned by the visible rows.
            // r = h / (2 * tan(pi / itemsOnArc)); fall back to a sane constant.
            const onArc = Math.max(3, this.visibleCount * 2);
            this._radius = this.itemHeight / (2 * Math.tan(Math.PI / onArc));
            if (!isFinite(this._radius) || this._radius <= 0) {
                this._radius = this.itemHeight * (this.visibleCount / 2);
            }

            // Seed the selected index from the configured value.
            const start = this.options.findIndex((o) => String(o.value) === String(config.value));
            this.index = start >= 0 ? start : 0;
            this.offset = -this.index * this.itemHeight;
        },

        get selectedValue() {
            return this.options[this.index]?.value ?? '';
        },

        /**
         * Modelable value (`x-modelable="model"` on the root): the selected
         * option value. An inbound value (Livewire, `x-model`) spins the wheel
         * to that option; an unknown value is ignored.
         */
        get model() {
            return this.selectedValue;
        },
        set model(v) {
            const i = this.options.findIndex((o) => String(o.value) === String(v));
            if (i === -1 || i === this.index) return;
            this.index = i;
            this.offset = -this.index * this.itemHeight;
        },

        clampIndex(i) {
            return Math.min(this.options.length - 1, Math.max(0, i));
        },

        // Distance of item `i` from the centre line, in item slots (can be
        // fractional during a drag). 0 = exactly on the selection line.
        distance(i) {
            return i + this.offset / this.itemHeight;
        },

        // Per-item transform/opacity. Flat list under reduced motion.
        itemStyle(i) {
            const d = this.distance(i);
            const h = this.itemHeight;

            if (this._reduced) {
                const opacity = Math.max(0.25, 1 - Math.abs(d) * 0.25);
                const pointer = Math.abs(d) <= 1 ? 'auto' : 'none';
                return `height:${h}px; line-height:${h}px; transform: translateY(${d * h - h / 2}px); opacity:${opacity}; pointer-events:${pointer};`;
            }

            const angle = d * (360 / Math.max(3, this.visibleCount * 2));
            // Items rotated past the horizon are hidden.
            if (Math.abs(angle) >= 90) {
                return `height:${h}px; line-height:${h}px; opacity:0; pointer-events:none; transform: rotateX(${angle}deg) translateZ(${this._radius}px);`;
            }
            const opacity = Math.max(0, Math.cos((angle * Math.PI) / 180));
            const pointer = Math.abs(d) <= 1 ? 'auto' : 'none';
            return `height:${h}px; line-height:${h}px; transform: translateY(${-h / 2}px) rotateX(${-angle}deg) translateZ(${this._radius}px); opacity:${opacity}; pointer-events:${pointer};`;
        },

        // Recompute the selected index from the live offset and notify on change.
        syncIndex() {
            const next = this.clampIndex(Math.round(-this.offset / this.itemHeight));
            if (next !== this.index) {
                this.index = next;
                this.emit();
            }
        },

        emit() {
            this.$el.dispatchEvent(
                new CustomEvent('change', {
                    detail: { value: this.selectedValue },
                    bubbles: true,
                }),
            );
        },

        // Snap the offset to the nearest valid item with a transition.
        snap() {
            this.index = this.clampIndex(Math.round(-this.offset / this.itemHeight));
            this.transitioning = !this._reduced;
            this.offset = -this.index * this.itemHeight;
            this.emit();
            if (this.transitioning) {
                window.setTimeout(() => {
                    this.transitioning = false;
                }, 200);
            }
        },

        selectIndex(i) {
            this.index = this.clampIndex(i);
            this.transitioning = !this._reduced;
            this.offset = -this.index * this.itemHeight;
            this.emit();
            if (this.transitioning) {
                window.setTimeout(() => {
                    this.transitioning = false;
                }, 200);
            }
        },

        startDrag(e) {
            e.preventDefault();
            this.transitioning = false;
            this._lastClientY = e.clientY;

            this._moveHandler = (ev) => {
                const dy = ev.clientY - this._lastClientY;
                this._lastClientY = ev.clientY;
                // Soft clamp: allow a little overscroll past the ends.
                const min = -(this.options.length - 1) * this.itemHeight - this.itemHeight;
                const max = this.itemHeight;
                this.offset = Math.min(max, Math.max(min, this.offset + dy));
                this.syncIndex();
            };
            this._upHandler = () => {
                this.removeDragListeners();
                this.snap();
            };

            document.addEventListener('pointermove', this._moveHandler);
            document.addEventListener('pointerup', this._upHandler);
            document.addEventListener('pointercancel', this._upHandler);
        },

        removeDragListeners() {
            if (this._moveHandler) {
                document.removeEventListener('pointermove', this._moveHandler);
                this._moveHandler = null;
            }
            if (this._upHandler) {
                document.removeEventListener('pointerup', this._upHandler);
                document.removeEventListener('pointercancel', this._upHandler);
                this._upHandler = null;
            }
        },

        onWheel(e) {
            e.preventDefault();
            const step = e.deltaY > 0 ? 1 : -1;
            this.selectIndex(this.index + step);
        },

        onKey(e) {
            switch (e.key) {
                case 'ArrowUp':
                case 'ArrowLeft':
                    e.preventDefault();
                    this.selectIndex(this.index - 1);
                    break;
                case 'ArrowDown':
                case 'ArrowRight':
                    e.preventDefault();
                    this.selectIndex(this.index + 1);
                    break;
                case 'Home':
                    e.preventDefault();
                    this.selectIndex(0);
                    break;
                case 'End':
                    e.preventDefault();
                    this.selectIndex(this.options.length - 1);
                    break;
            }
        },

        destroy() {
            this.removeDragListeners();
        },
    }));
});

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
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0