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.
Category Forms Type Web Component (<mv-wheel-picker>) Status beta Keywords wheel, picker, drum, ios, 3d, momentum, snap, date, time, listbox, form-associated
When to use
A mobile-first form picks a time, duration or date by flicking iOS-style drums Several columns such as hours and minutes must be combined under one selection band A short numeric range or list should be chosen with touch momentum and snapping
Avoid when
Desktop users pick a calendar date or range → use Date Picker instead The list is long and users know what they want to type → use Combobox instead A plain choice from a few options is enough → use Select instead
Get Wheel Picker. Included in every plan: the Pro pack ships all 226 components with the CLI and the MCP server used below.
See plans
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
Name Type Default Description valuestring Initial value (option value). Restored on form reset. namestring Field name in the FormData. labelstring Accessible 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 / stepnumber 0 / 10 / 1Numeric range generated when no list is provided. padnumber Zero-pads numbers (pad="2" → 07). loopboolean Infinite drum (hours, minutes): always spins along the shortest path. visiblenumber 5Number of visible rows (height = rows × item height). unitstring Fixed unit to the right of the band (“h”, “min”). alignstart | center | end centerText alignment within the column. disabled / requiredboolean Standard states (disabled is also inherited from a <fieldset disabled>).
Properties
Name Type Description valuestring Selected 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
Name Description focus()Focuses the wheel.
Events
Name Description 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
Name Description <option>Child options (value, label, disabled, selected).
CSS classes
Name Description 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
Name Default Description --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.