Mischief

128 / Controls

Wheel Picker

A scrolling wheel for one value out of a long ordered list: an hour, a minute, a year, a size. Several wheels side by side make a time picker.

Daily reminder

We will nudge you at 7:30 AM.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/wheel-picker
import { WheelPicker } from "mischief-ui/wheel-picker"

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/wheel-picker/wheel-picker.tsx
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type WheelPickerOption = {  value: string  label: string} export type WheelPickerProps = Omit<  React.HTMLAttributes<HTMLDivElement>,  "onChange" | "defaultValue" | "children"

Usage

const hours = Array.from({ length: 12 }, (_, index) => ({
  value: String(index + 1),
  label: String(index + 1),
}))

const minutes = Array.from({ length: 12 }, (_, index) => {
  const minute = String(index * 5).padStart(2, "0")
  return { value: minute, label: minute }
})

export function ReminderTime() {
  const [hour, setHour] = React.useState("7")
  const [minute, setMinute] = React.useState("30")
  const [period, setPeriod] = React.useState("AM")

  return (
    <WheelPickerGroup label="Reminder time">
      <WheelPicker label="Hour" loop options={hours} value={hour} onValueChange={setHour} />
      <WheelPicker label="Minute" loop options={minutes} value={minute} onValueChange={setMinute} />
      <WheelPicker
        label="AM or PM"
        options={[
          { value: "AM", label: "AM" },
          { value: "PM", label: "PM" },
        ]}
        value={period}
        onValueChange={setPeriod}
      />
    </WheelPickerGroup>
  )
}

The browser does the scrolling

The wheel is an ordinary scroll container with rows snapped to the middle, so a flick on a phone or a trackpad carries the momentum that device already has. A mouse wheel moves it a row at a time, and a mouse can also drag it and throw it a short way. Clicking a row brings it to the middle.

The value changes once the wheel comes to rest, not for every row that passes the band, so onValueChange fires once per choice rather than once per frame.

Looping

loop runs past the last option back to the first, which suits minutes and hours. The rows are repeated enough times that a hard flick runs out of momentum before it runs out of rows, and the wheel is moved back to the middle copy without a visible jump once it rests. The arrow keys wrap too. Page Up, Page Down, Home and End stop at the ends.

Several wheels as one value

WheelPickerGroup lines wheels up behind a single selection band and gives the set a name. Left and right arrows move between its wheels. Each wheel still has its own label, value and onValueChange, so the parts of a time are kept and combined however your form wants them.

Why it is a spinbutton

Each wheel is a spinbutton with the option's label as its value text, not a listbox. A spinbutton is what VoiceOver on iOS treats as adjustable, so a swipe up or down changes the value the way it does on the native picker, and desktop screen readers read the new label after each arrow key. A listbox leaning on aria-activedescendant is announced unevenly on mobile screen readers, and a wheel is mostly used on phones.

API

optionsreadonly WheelPickerOption[]The rows, in order, as { value, label }.
valuestringThe chosen value, for a controlled wheel.
defaultValuestringThe starting value. Defaults to the first option.
onValueChange(value: string) => voidCalled once the wheel rests on a new row, or on each key press.
labelstringThe accessible name, such as Hour.
loopbooleanRuns past the last option back to the first.
visibleCountnumberRows on screen at once, made odd so one sits in the middle. Defaults to 5.
itemHeightnumberRow height in pixels. Defaults to 36.
disabledbooleanStops input and removes the wheel from the tab order.
namestringSubmits the chosen value with a form under this name.
classNamestringClasses for the wheel, such as a width.
...divPropsHTMLAttributesNative div attributes.
label (WheelPickerGroup)stringThe accessible name for the set, such as Start time.
visibleCount, itemHeight (WheelPickerGroup)numberShared by every wheel inside, so their rows line up.
children (WheelPickerGroup)ReactNodeThe wheels, in reading order.

Accessibility

Each wheel is a focusable spinbutton named by label, with the option's label as its value text. Up and Down move one row, Page Up and Page Down move a window, Home and End go to the ends, and typing jumps to the first option that starts with what was typed, cycling on a repeated letter. Keyboard moves are instant rather than animated. A group is a named group, and Left and Right move between its wheels. The rows are drawn for sighted readers and hidden from assistive technology, which reads the value text instead. Reduced motion lays the drum flat: the rows no longer tilt or shrink, the faded edges remain, and a click or a drag lands without a smooth scroll.