bêta
Wheel Picker <mv-wheel-picker>
Sélecteur à roue façon iOS : un tambour 3D (rotateX + perspective) qu’on lance d’un geste du doigt, avec de l’inertie puis un calage sur le cran le plus proche, ainsi que la molette de la souris, le clavier et la recherche par saisie. Plusieurs colonnes se combinent sous une bande commune pour les sélecteurs de date ou d’heure. Associé aux formulaires natifs.
Catégorie Formulaires Type Web Component (<mv-wheel-picker>) Statut bêta 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
Obtenir Wheel Picker. Inclus dans toutes les offres : le Pro pack contient les 317 composants, avec la CLI et le serveur MCP utilisés ci-dessous.
Voir les offres
Installation
node scripts/add.mjs wheel-picker --out ./src/marvelous
Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["wheel-picker"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<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.