WheelPicker

A vertical single-select listbox where the selection follows the cursor, in the manner of a scroll wheel: moving with the arrow keys, typing, or clicking an option chooses it. Use it to pick one of a short list (an hour, a minute, a month, a year) where a long <select> is awkward. It is the natural control for the time part of DateTimePicker.

Status: experimental — implemented and unit-tested in all nine headless catalogs (2026-10-23). Blazor, Nunjucks and plain HTML differ; see "Per-library notes".

Example

January
February
March
April
May
June
July
August
September
October
November
December
Show demo markup
<div class="wheel-picker"><div class="wheel-picker-list" role="listbox" aria-label="Month" aria-orientation="vertical" aria-activedescendant="wheel-demo-option-2" tabindex="0"><div class="wheel-picker-option" role="option" id="wheel-demo-option-0" aria-selected="false" data-value="1" data-index="0">January</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-1" aria-selected="false" data-value="2" data-index="1">February</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-2" aria-selected="true" data-value="3" data-index="2">March</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-3" aria-selected="false" data-value="4" data-index="3">April</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-4" aria-selected="false" data-value="5" data-index="4">May</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-5" aria-selected="false" data-value="6" data-index="5">June</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-6" aria-selected="false" data-value="7" data-index="6">July</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-7" aria-selected="false" data-value="8" data-index="7">August</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-8" aria-selected="false" data-value="9" data-index="8">September</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-9" aria-selected="false" data-value="10" data-index="9">October</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-10" aria-selected="false" data-value="11" data-index="10">November</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-11" aria-selected="false" data-value="12" data-index="11">December</div></div></div>
Usage example
<div class="wheel-picker"><div class="wheel-picker-list" role="listbox" aria-label="Month" aria-orientation="vertical" aria-activedescendant="wheel-demo-option-2" tabindex="0"><div class="wheel-picker-option" role="option" id="wheel-demo-option-0" aria-selected="false" data-value="1" data-index="0">January</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-1" aria-selected="false" data-value="2" data-index="1">February</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-2" aria-selected="true" data-value="3" data-index="2">March</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-3" aria-selected="false" data-value="4" data-index="3">April</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-4" aria-selected="false" data-value="5" data-index="4">May</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-5" aria-selected="false" data-value="6" data-index="5">June</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-6" aria-selected="false" data-value="7" data-index="6">July</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-7" aria-selected="false" data-value="8" data-index="7">August</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-8" aria-selected="false" data-value="9" data-index="8">September</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-9" aria-selected="false" data-value="10" data-index="9">October</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-10" aria-selected="false" data-value="11" data-index="10">November</div><div class="wheel-picker-option" role="option" id="wheel-demo-option-11" aria-selected="false" data-value="12" data-index="11">December</div></div></div>
Show Svelte source
// In your Svelte component:
import WheelPicker from "lily-design-system-svelte-headless/components/WheelPicker/WheelPicker.svelte";

<WheelPicker>
  <!-- content -->
</WheelPicker>

Usage

<WheelPicker label="Month" options={months} bind:value={month} />
/* consumer CSS: the 57 themes already do this */
.wheel-picker-list { max-block-size: 10em; overflow-y: auto; scroll-snap-type: y mandatory; }
.wheel-picker-option { scroll-snap-align: center; }
.wheel-picker-option[aria-selected="true"] { font-weight: 600; }

Props

Prop Type Default Description
class string "" Appended to the base class
label string (required) — Accessible name
options { value, label, disabled? }[] (required) — The choices
value string "" The chosen option's value. Bindable; empty or unknown chooses nothing
loop boolean false Arrow keys wrap from the last enabled option to the first and back
pageSize number 5 Enabled options PageUp / PageDown move by
disabled boolean false Not focusable; ignores keys and clicks
name string — Adds a hidden input carrying the value
onChange (value) => void — Once per change
...restProps HTML attributes — Spread onto the root <div>

Per-library notes

  • Svelte bind:value; React controlled (value and onChange); Vue v-model; Angular [(value)], no rest-props spread; Web Components reads child <option> elements (or the options property) and fires change; Alpine lilyWheelPicker({ options, value, … }) fires change with { value }.
  • HTML: the options are in the markup; the snippet's <script> handles the keys, clicks and the hidden input, and fires a bubbling change with detail.value. The key model inside the script is generated from the shared module between its BEGIN/END markers.
  • Nunjucks: a server cannot handle keys, so the macro renders the markup contract only (selected option, aria-activedescendant, data-loop, data-page-size); add the HTML script or use the Web Components element for the behaviour.
  • Blazor (no JS interop): @bind-Value, key model WheelPickerKeys. A handler cannot call preventDefault for only some keys, so the arrow and page keys also scroll the list (give .wheel-picker-list overflow: hidden to stop that), and the chosen option is not re-centred by script.

Keyboard Interactions

Key Action
ArrowDown / ArrowUp Choose the next / previous enabled option (wrap only with loop)
Home / End Choose the first / last enabled option
PageDown / PageUp Move by pageSize enabled options, clamped
A printable character Type-ahead over the labels
Tab Leave the control

ARIA

  • role="listbox", aria-label, aria-orientation="vertical", aria-activedescendant (the chosen option), aria-disabled when disabled
  • Options: role="option", aria-selected, aria-disabled when disabled

When to Use

  • Choosing one value from a short, ordered list: hours, minutes, months, years, quantities
  • Time and date entry where the reader scrolls or types to the value
  • When a visible, scannable list is better than opening a dropdown

When Not to Use

  • Use Select or Combobox — for a long list, or one the reader should search
  • Use RadioGroup — for two to five choices that should all be visible at once
  • Use Slider — for a continuous or very wide numeric range
  • Use DateTimePicker — for a full date and time, which composes this for its time columns
  • Do not use it for several selections: it is single-select

Headless

This component decides the roles, the keys and the chosen value. It decides no height, snapping, fading, colour or motion; the consumer's CSS supplies the wheel look.

Styles

Target .wheel-picker, .wheel-picker-list (give it a height and overflow-y: auto, with scroll-snap-type: y mandatory) and .wheel-picker-option (scroll-snap-align: center). Style the chosen option with [aria-selected="true"] and a disabled one with [aria-disabled="true"]. Never remove the list's focus ring. Under prefers-reduced-motion, drop any scroll smoothing, fade or tilt.

Testing

  • Base class first, then the consumer class; a vertical, named listbox
  • One option per entry, exactly one aria-selected, none for an unknown value; aria-activedescendant names it
  • Arrow, Home, End and Page keys move the choice, skip disabled options, clamp or wrap with loop
  • Type-ahead chooses the matching option
  • Handled keys prevent the default action
  • Clicking chooses an enabled option; a disabled one does nothing; the held option is a no-op
  • Disabled: not focusable, ignores keys and clicks
  • Hidden input only with name, carrying the value; rest props reach the root

Advice

Give the list a fixed height showing three to seven options, so the chosen one has neighbours on both sides. Keep labels short and put the unit in label, not in every option. For a form field, give the picker a visible label and name.

A wheel is easy to use with a pointer and keyboard but is a poor fit for screen-magnifier users who see one option at a time: for a long list, offer a Select instead.

Implementation Notes

  • Renders <div class="wheel-picker {class}"> holding <div class="wheel-picker-list" role="listbox" aria-orientation="vertical" tabindex="0"> of <div class="wheel-picker-option" role="option" aria-selected> elements
  • DOM focus stays on the list; the cursor is aria-activedescendant. Selection follows the cursor, so there is no commit key (APG listbox, "selection follows focus")
  • The keys, the skipping of disabled options, the wrap rule and type-ahead are the pure shared/wheel-picker.ts
  • Centres the chosen option by setting the list's scrollTop: only the list scrolls, never the page, and never animated
  • The wheel look (scroll snapping, a fading edge, a 3-D tilt, momentum on touch) is the consumer's CSS. Dragging and touch inertia are plain scrolling of .wheel-picker-list
  • The 57 reference themes style it as a scroll-snapping list with the chosen option filled and bold

Related components

  • listbox
  • select
  • combobox
  • date-time-picker
  • slider

References


Lily™ and Lily Design System™ are trademarks.

Canonical contract

Generated from this component's canonical metadata — the machine-checked source the implementations are held to.

Metadata

  • Component: wheel-picker
  • PascalCase: WheelPicker
  • Description: a vertical single-select listbox where the selection follows the cursor, for choosing one of a short list by scrolling or typing
  • Status: experimental — implemented and unit-tested in all nine headless catalogs (2026-10-23); Blazor and plain HTML/Nunjucks differ, see index.md
  • HTML tag: <div>
  • CSS class: .wheel-picker
  • Interactive: yes

ARIA

  • role="listbox" with aria-label, aria-orientation="vertical" and aria-activedescendant naming the chosen option; aria-disabled="true" when disabled
  • Each option: role="option", aria-selected, aria-disabled="true" when disabled
  • DOM focus stays on the listbox; the cursor is aria-activedescendant

Keyboard

  • ArrowDown / ArrowUp: choose the next / previous enabled option (wrap only with loop)
  • Home / End: choose the first / last enabled option
  • PageDown / PageUp: move by pageSize enabled options, clamped
  • Printable character: type-ahead over the labels
  • Tab: leaves the control

Props

  • class: string (default: "") -- appended to the base class
  • label: string (required) -- accessible name
  • options: { value: string, label: string, disabled?: boolean }[] (required)
  • value: string (default: "") -- the chosen option's value; bindable (Svelte bind:value, Vue v-model, Angular [(value)], Blazor @bind-Value, React controlled with onChange)
  • loop: boolean (default: false)
  • pageSize: number (default: 5)
  • disabled: boolean (default: false) -- not focusable; ignores keys and clicks
  • name: string -- adds a hidden input
  • onChange: (value: string) => void -- once per change (React, Svelte); change event in Web Components, Alpine and HTML
  • ...restProps: HTML attributes -- spread onto the root <div>

Markdown

This page as Markdown, for people and AI tools: wheel-picker.md