Lunia DS

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.

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-picker

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
itemHeightnumber36WheelPicker: row height in px, clamped to 28-56; the drum radius follows from it.
hapticsbooleantrueWheelPicker: 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.
valueTundefinedWheel: controlled value.
defaultValueTfirst optionWheel: starting value when uncontrolled.
loopbooleanfalseWheel: turn past the end back to the start, for example minutes.
labelstring-Wheel: what the wheel sets, for screen readers, for example "Hours". Required.
unitstring | ((value: T) => string)undefinedWheel: 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.
EventDescription
valueChangeWheel: fires once the wheel comes to rest on a new option, not while it turns.
SlotDescription
defaultWheelPicker 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
WP1drag or trackpad scrolldrum + lens copytransform rotateX (one row per row height; rubber band past the ends of a wheel that does not loop)current row → pointer position0ease-linearMoves at once
WP2release after a flingdrum + lens copytransform rotateX (release speed projected 0.3s ahead, rounded to a row, then slows onto it)release point → projected rowduration-6xl (900)ease-outMoves at once
WP3release with little speed, or a trackpad goes quiet for 120msdrum + lens copytransform rotateX (snap to the nearest row)between rows → nearest rowduration-xl (380)ease-outMoves at once
WP4release past an end (no loop)drum + lens copytransform rotateX (back from the stretch)past the end → first or last rowduration-2xl (420)ease-outMoves at once
WP5Arrow Up or Down, Page Up or Down, a mouse wheel notchdrum + lens copytransform rotateX (1 or 5 rows)current row → next rowduration-lg (320)ease-outMoves at once
WP6tap a row above or below the lens, Home or End, or value set from outsidedrum + lens copytransform rotateX (loops take the shorter way)current row → chosen rowduration-3xl (460)ease-outMoves at once
WP7wheel rests on a disabled optiondrum + lens copytransform rotateX (on to the nearest enabled option)disabled row → nearest enabled rowduration-3xl (460)ease-outMoves at once
WP8wheel rests on a new valueunittext morph (for example "1 hour" to "2 hours")old unit → new unitduration-3xl (460)ease-outSwaps 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.