Select
Pick a value from a list — the native select, a custom listbox, a filterable combobox or a scrolling wheel.
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
<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
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:
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.
-
resources/views/components/ui/wheel-picker.blade.php -
resources/js/ui/wheel-picker.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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>
/**
* 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