Actions and inputs
Wheel picker
A set of 3D drum wheels you drag or fling, where options turn on a cylinder, the middle row reads clearly through a lens band, and each wheel slows down onto a row before it sets the value.
Live preview
Loading preview
A "Remind me in" card with three wheels: hours 0-23 with unit "hour" or "hours", minutes 0-55 in steps of 5 with loop and unit "min", and a day wheel (Today, Tomorrow, Friday) where past options are disabled. Replay sets 1 hour 30 min Today, then flings the hours wheel to 4 and turns the minutes to 15.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add wheel-pickerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { Button } from '../button/button';
import { Wheel, WheelPicker } from './wheel-picker';
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
// A past day stays in the list but cannot be picked.
const DAYS = [
{ value: 'monday', label: 'Monday', disabled: true },
{ value: 'today', label: 'Today' },
{ value: 'tomorrow', label: 'Tomorrow' },
{ value: 'friday', label: 'Friday' },
];
const START = { hours: 1, minutes: 30, day: 'today' };
const hourUnit = (h: number) => (h === 1 ? 'hour' : 'hours');
export default function WheelPickerDemo({ replay = 0 }: { replay?: number }) {
const [hours, setHours] = React.useState(START.hours);
const [minutes, setMinutes] = React.useState(START.minutes);
const [day, setDay] = React.useState(START.day);
const [saved, setSaved] = React.useState('');
const timers = React.useRef<number[]>([]);
const reset = React.useCallback(() => {
setHours(START.hours);
setMinutes(START.minutes);
setDay(START.day);
setSaved('');
}, []);
React.useEffect(() => {
if (replay === 0) return;
for (const timer of timers.current) window.clearTimeout(timer);
reset();
timers.current = [
window.setTimeout(() => setHours(4), 700),
window.setTimeout(() => setMinutes(15), 1500),
];
}, [replay, reset]);
React.useEffect(
() => () => {
for (const timer of timers.current) window.clearTimeout(timer);
},
[],
);
const dayLabel = DAYS.find((d) => d.value === day)?.label.toLowerCase() ?? '';
return (
<div className="flex w-full max-w-[386px] flex-col gap-6 rounded-2xl border border-secondary bg-primary p-8 shadow-xs">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold text-primary">Remind me in</h3>
<p className="text-sm text-tertiary" aria-live="polite">
{saved || 'You get a notification at that time.'}
</p>
</div>
<WheelPicker aria-label="Reminder time">
<Wheel label="Hours" options={HOURS} value={hours} onValueChange={setHours} unit={hourUnit} />
<Wheel label="Minutes" options={MINUTES} value={minutes} onValueChange={setMinutes} loop unit="min" />
<Wheel label="Day" options={DAYS} value={day} onValueChange={setDay} className="flex-[1.2]" />
</WheelPicker>
<div className="flex justify-end gap-3">
<Button hierarchy="secondary" onClick={reset}>
Cancel
</Button>
<Button
onClick={() =>
setSaved(`Reminder set: ${dayLabel}, in ${hours} ${hourUnit(hours)} ${minutes} min.`)
}
>
Set reminder
</Button>
</div>
</div>
);
}
API
Parts: root (group, one per picker), lens band (muted bar across the middle), wheel (spinbutton column), drum (18 faces on a cylinder, faded to the rim), lens copy (same drum in full color, clipped to the band), face (option label), unit (text morph, fixed beside the lens).
| Prop | Type | Default | Description |
|---|---|---|---|
| itemHeight | number | 36 | WheelPicker: row height in px, clamped to 28-56; the drum radius follows from it. |
| haptics | boolean | true | WheelPicker: a short vibration per row on phones that support it. |
| options | (T | { value: T; label?: string; disabled?: boolean })[] | - | Wheel: the options; T is string or number. Disabled options are dimmed and skipped. Required. |
| value | T | undefined | Wheel: controlled value. |
| defaultValue | T | first option | Wheel: starting value when uncontrolled. |
| loop | boolean | false | Wheel: turn past the end back to the start, for example minutes. |
| label | string | - | Wheel: what the wheel sets, for screen readers, for example "Hours". Required. |
| unit | string | ((value: T) => string) | undefined | Wheel: fixed text beside the lens, for example "min", or a function for "1 hour" and "2 hours". |
| align | "start" | "center" | "end" | "center" | Wheel: where the options sit in their column. |
| Event | Description |
|---|---|
| valueChange | Wheel: fires once the wheel comes to rest on a new option, not while it turns. |
| Slot | Description |
|---|---|
| default | WheelPicker holds one or more Wheel children side by side. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| WP1 | drag or trackpad scroll | drum + lens copy | transform rotateX (one row per row height; rubber band past the ends of a wheel that does not loop) | current row → pointer position | 0ease-linear | Moves at once |
| WP2 | release after a fling | drum + lens copy | transform rotateX (release speed projected 0.3s ahead, rounded to a row, then slows onto it) | release point → projected row | duration-6xl (900)ease-out | Moves at once |
| WP3 | release with little speed, or a trackpad goes quiet for 120ms | drum + lens copy | transform rotateX (snap to the nearest row) | between rows → nearest row | duration-xl (380)ease-out | Moves at once |
| WP4 | release past an end (no loop) | drum + lens copy | transform rotateX (back from the stretch) | past the end → first or last row | duration-2xl (420)ease-out | Moves at once |
| WP5 | Arrow Up or Down, Page Up or Down, a mouse wheel notch | drum + lens copy | transform rotateX (1 or 5 rows) | current row → next row | duration-lg (320)ease-out | Moves at once |
| WP6 | tap a row above or below the lens, Home or End, or value set from outside | drum + lens copy | transform rotateX (loops take the shorter way) | current row → chosen row | duration-3xl (460)ease-out | Moves at once |
| WP7 | wheel rests on a disabled option | drum + lens copy | transform rotateX (on to the nearest enabled option) | disabled row → nearest enabled row | duration-3xl (460)ease-out | Moves at once |
| WP8 | wheel rests on a new value | unit | text morph (for example "1 hour" to "2 hours") | old unit → new unit | duration-3xl (460)ease-out | Swaps at once |
Accessibility
- Each wheel is a spinbutton with aria-label, aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext (option label plus unit, for example "2 hours"); the drum faces are aria-hidden.
- Keyboard: Arrow Up and Down move one row, Page Up and Page Down move five, Home and End go to the first and last option.
- The value changes only when the wheel rests, so screen readers and the app get one value, not every row it passes.
- Focus ring on the lens of the focused wheel (focus-ring token).
- Rows are 36px by default (28px minimum), above the 24px minimum target; touch-action none on the wheel so dragging does not scroll the page.
- A 3ms vibration per row on touch drags, at most every 45ms, when haptics is on and the browser allows it.
- Reduced motion: every turn jumps straight to its row; dragging still follows the pointer because it is direct input.