beta

Wheel Picker — <mv-wheel-picker>

iOS-style wheel picker: a 3D drum (rotateX + perspective) you flick with a finger, with momentum then snapping to the nearest notch, plus mouse wheel, keyboard and type-ahead. Several columns combine under a shared band for date or time pickers. Form-associated.

CategoryForms
TypeWeb Component (<mv-wheel-picker>)
Statusbeta
Keywordswheel, picker, drum, ios, 3d, momentum, snap, date, time, listbox, form-associated

When to use

Avoid when

Install

node scripts/add.mjs wheel-picker --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["wheel-picker"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/form.js, core/motion.js, core/observe.js, components/wheel-picker/wheel-picker.js, components/wheel-picker/wheel-picker.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="wp-demo" style="display:flex;flex-wrap:wrap;gap:1.5rem;justify-content:center;align-items:stretch;width:100%;max-width:48rem">
  <form data-form="birth" style="flex:1 1 20rem;display:grid;gap:.875rem;justify-items:center;padding:1.25rem 1rem 1rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
    <div style="justify-self:stretch;display:flex;justify-content:space-between;align-items:baseline;gap:1rem">
      <strong style="font-size:.9375rem">Date of birth</strong>
      <span data-out="birth" style="font-size:.8125rem;color:var(--mv-fg-muted)" aria-live="polite"></span>
    </div>
    <div class="mv-wheel-picker-group" data-date-group>
      <mv-wheel-picker name="month" label="Month" value="3" align="end" data-part="month" style="--mv-wheel-picker-font-size:1.125rem">
        <option value="1">January</option><option value="2">February</option><option value="3">March</option>
        <option value="4">April</option><option value="5">May</option><option value="6">June</option>
        <option value="7">July</option><option value="8">August</option><option value="9">September</option>
        <option value="10">October</option><option value="11">November</option><option value="12">December</option>
      </mv-wheel-picker>
      <mv-wheel-picker name="day" label="Day" min="1" max="31" value="14" data-part="day"></mv-wheel-picker>
      <mv-wheel-picker name="year" label="Year" min="1940" max="2026" value="1995" data-part="year"></mv-wheel-picker>
    </div>
    <p style="margin:0;font-size:.75rem;color:var(--mv-fg-subtle);text-align:center">Drag, flick, scroll — or use the arrows, Page ↑/↓ and type-ahead (“s” → September).</p>
  </form>

  <form data-form="alarm" style="flex:1 1 16rem;display:grid;gap:.875rem;justify-items:center;padding:1.25rem 1rem 1rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
    <div style="justify-self:stretch;display:flex;justify-content:space-between;align-items:baseline;gap:1rem">
      <strong style="font-size:.9375rem">Alarm</strong>
      <span data-out="alarm" style="font-size:.8125rem;color:var(--mv-fg-muted)" aria-live="polite"></span>
    </div>
    <div class="mv-wheel-picker-group" style="--mv-wheel-picker-font-size:1.5rem;--mv-wheel-picker-item-height:2.5rem">
      <mv-wheel-picker name="hour" label="Hours" min="0" max="23" pad="2" loop value="7" unit="h"></mv-wheel-picker>
      <mv-wheel-picker name="minute" label="Minutes" min="0" max="55" step="5" pad="2" loop value="30" unit="min"></mv-wheel-picker>
    </div>
    <p style="margin:0;font-size:.75rem;color:var(--mv-fg-subtle);text-align:center">Infinitely looping columns, 5-minute steps.</p>
  </form>
</div>
<script type="module">
  const root = document.getElementById("wp-demo");
  const locale = "en-US";
  const parts = Object.fromEntries(["day", "month", "year"].map((p) => [p, root.querySelector(`[data-part="${p}"]`)]));
  const { day, month, year } = parts;
  // Column order follows the locale (en-US: Month / Day / Year; en-GB: Day / Month / Year).
  const order = new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" })
    .formatToParts(new Date(2000, 11, 31)).map((p) => p.type).filter((t) => t in parts);
  const group = root.querySelector("[data-date-group]");
  if ([...group.children].some((el, i) => el.dataset.part !== order[i])) group.append(...order.map((t) => parts[t]));
  const mi = order.indexOf("month");
  month.setAttribute("align", mi === 0 ? "end" : mi === order.length - 1 ? "start" : "center");
  const birthOut = root.querySelector('[data-out="birth"]');
  const fullDate = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
  const renderBirth = () => {
    const d = Number(day.value), m = Number(month.value), y = Number(year.value);
    if (!d || !m || !y) return;
    birthOut.textContent = fullDate.format(new Date(y, m - 1, d));
  };
  // Month length follows month and year (Feb 29 in leap years).
  const syncDays = () => {
    const len = new Date(Number(year.value), Number(month.value), 0).getDate();
    if (day.options.length !== len) day.setAttribute("max", String(len));
    renderBirth();
  };
  for (const p of [month, year]) p.addEventListener("mv-change", syncDays);
  root.addEventListener("mv-input", (e) => { if (e.target.closest('[data-form="birth"]')) renderBirth(); });

  const alarm = root.querySelector('[data-form="alarm"]');
  const alarmOut = root.querySelector('[data-out="alarm"]');
  const renderAlarm = () => {
    const fd = new FormData(alarm);
    const h = Number(fd.get("hour")), m = Number(fd.get("minute"));
    const now = new Date();
    let diff = (h * 60 + m) - (now.getHours() * 60 + now.getMinutes());
    if (diff <= 0) diff += 1440;
    alarmOut.textContent = `Rings in ${Math.floor(diff / 60)}h ${String(diff % 60).padStart(2, "0")}m`;
  };
  alarm.addEventListener("mv-input", renderAlarm);
  alarm.addEventListener("mv-change", renderAlarm);
  customElements.whenDefined("mv-wheel-picker").then(() => requestAnimationFrame(() => { syncDays(); renderAlarm(); }));
  for (const f of root.querySelectorAll("form")) f.addEventListener("submit", (e) => e.preventDefault());
</script>

API

Attributes

NameTypeDefaultDescription
valuestringInitial value (option value). Restored on form reset.
namestringField name in the FormData.
labelstringAccessible name of the list (or aria-labelledby / <label for>).
optionsstring“a,b,c” list when there is neither a child <option> nor an options property.
min / max / stepnumber0 / 10 / 1Numeric range generated when no list is provided.
padnumberZero-pads numbers (pad="2" → 07).
loopbooleanInfinite drum (hours, minutes): always spins along the shortest path.
visiblenumber5Number of visible rows (height = rows × item height).
unitstringFixed unit to the right of the band (“h”, “min”).
alignstart | center | endcenterText alignment within the column.
disabled / requiredbooleanStandard states (disabled is also inherited from a <fieldset disabled>).

Properties

NameTypeDescription
valuestringSelected value; setting it spins the wheel.
optionsArray<string | { value, label, disabled }>Option list in JS (takes precedence). Disabled options are skipped.
selectedIndex / selectedOptionCurrent index and option.
form / labels / validityThrough ElementInternals.

Methods

NameDescription
focus()Focuses the wheel.

Events

NameDescription
mv-inputFires each time a new option passes under the band. detail: { value, label, index }.
mv-changeFires when the wheel settles on a different value.

Content structure

NameDescription
<option>Child options (value, label, disabled, selected).

CSS classes

NameDescription
mv-wheel-picker-groupContainer for several side-by-side wheels with a shared selection band.
mv-wheel-picker-viewport / -drum / -lens / -item / -band / -unitGenerated parts.

CSS variables

NameDefaultDescription
--mv-wheel-picker-item-height2.25remRow height.
--mv-wheel-picker-font-sizevar(--mv-text-xl)Text size.
--mv-wheel-picker-bandvar(--mv-bg-muted)Selection band background.
--mv-wheel-picker-mutedvar(--mv-fg-subtle)Color of rows outside the band.

Accessibility

The wheel is a focusable role="listbox" with aria-activedescendant pointing to the selected option (role="option" items are visually hidden but operable by screen readers); the drawn drum is aria-hidden. Keyboard: ↑/↓ one notch, Page ↑/↓ five, Home/End, type-ahead on first letters (accent-insensitive). Reduced motion: no momentum, the wheel jumps straight to the value.