Wheel Picker
An iOS-style 3D scroll wheel with flick momentum that snaps to items; combine wheels for time pickers.
Wake up at 7:30 AM
1
2
3
4
5
6
7
8
9
10
11
12
00
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
AM
PM
Snooze
5 min
10 min
15 min
20 min
30 min
Drag or flick a wheel, scroll it with a trackpad or mouse wheel, tap a row, or focus it and use the arrow keys. Rows sit on a real cylinder (each one is placed with translateY, translateZ and rotateX from a single motion value), so dragging never re-renders Vue.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/wheel-picker.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/wheel-picker/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { AnimationPlaybackControls } from 'motion-v'
import type { HTMLAttributes } from 'vue'
import type { WheelPickerOption } from './context'
import { animate, useMotionValue, useMotionValueEvent, useReducedMotion } from 'motion-v'
import { computed, inject, onBeforeUnmount, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import { WHEEL_GROUP_KEY } from './context'
import WheelPickerItem from './WheelPickerItem.vue'
const props = withDefaults(defineProps<{
options: WheelPickerOption[]
/** Accessible name, e.g. "Hours". */
label?: string
/** Row height in px. Inherited from <WheelPickerGroup> when grouped. */
itemHeight?: number
/** Rows visible at once (odd). Inherited from <WheelPickerGroup> when grouped. */
visibleCount?: number
disabled?: boolean
class?: HTMLAttributes['class']
}>(), {
itemHeight: 36,
visibleCount: 5,
})
const model = defineModel<string | number>()
const group = inject(WHEEL_GROUP_KEY, null)
const itemHeight = computed(() => group?.itemHeight ?? props.itemHeight)
const visibleCount = computed(() => group?.visibleCount ?? props.visibleCount)
const items = computed(() =>
props.options.map(o => (typeof o === 'object' ? o : { value: o, label: String(o) })),
)
// Drum geometry: neighbours are `step` degrees apart on a cylinder whose
// chord between two items equals the row height.
const step = computed(() => 90 / ((visibleCount.value - 1) / 2 + 1.5))
const radius = computed(() => itemHeight.value / (2 * Math.sin((step.value * Math.PI) / 360)))
const snapSpring = { type: 'spring', stiffness: 320, damping: 34 } as const
const RUBBER = 0.3
const uid = useId()
const reduced = useReducedMotion()
const indexOf = (value: unknown) => Math.max(0, items.value.findIndex(i => i.value === value))
const lastIndex = computed(() => Math.max(0, items.value.length - 1))
const minOffset = computed(() => -lastIndex.value * itemHeight.value)
// Scroll position in px: item i is centred when offset = -i * itemHeight.
const offset = useMotionValue(-indexOf(model.value) * itemHeight.value)
const active = ref(indexOf(model.value))
const clampIndex = (i: number) => Math.min(lastIndex.value, Math.max(0, i))
const nearest = (o: number) => clampIndex(Math.round(-o / itemHeight.value))
useMotionValueEvent(offset, 'change', (o) => {
const i = nearest(o)
if (i !== active.value)
active.value = i
})
let anim: AnimationPlaybackControls | undefined
/** Row the wheel is heading to, so quick repeated key presses accumulate. */
let heading: number | null = null
function commit() {
heading = null
const i = nearest(offset.get())
const value = items.value[i]?.value
if (value !== undefined && value !== model.value)
model.value = value
}
function snapTo(i: number) {
anim?.stop()
heading = clampIndex(i)
const target = -heading * itemHeight.value
if (reduced.value) {
offset.set(target)
commit()
return
}
anim = animate(offset, target, { ...snapSpring, onComplete: commit })
}
watch(model, (v) => {
const i = indexOf(v)
if (i !== nearest(offset.get()))
snapTo(i)
})
watch(itemHeight, h => offset.jump(-active.value * h))
// ── drag with momentum ──────────────────────────────────────────────────────
let drag: { y: number, offset: number, moved: boolean, samples: { t: number, y: number }[] } | null = null
function rubberBand(o: number) {
if (o > 0)
return o * RUBBER
if (o < minOffset.value)
return minOffset.value + (o - minOffset.value) * RUBBER
return o
}
function onPointerDown(e: PointerEvent) {
if (props.disabled || e.button !== 0)
return
anim?.stop()
heading = null
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
drag = { y: e.clientY, offset: offset.get(), moved: false, samples: [{ t: e.timeStamp, y: e.clientY }] }
}
function onPointerMove(e: PointerEvent) {
if (!drag)
return
const dy = e.clientY - drag.y
if (Math.abs(dy) > 3)
drag.moved = true
offset.set(rubberBand(drag.offset + dy))
drag.samples.push({ t: e.timeStamp, y: e.clientY })
if (drag.samples.length > 6)
drag.samples.shift()
}
function onPointerUp(e: PointerEvent) {
if (!drag)
return
const { moved, samples } = drag
drag = null
if (!moved) {
// A tap: pick the row under the pointer by projecting back onto the drum.
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const dy = e.clientY - (rect.top + rect.height / 2)
const a = Math.asin(Math.max(-1, Math.min(1, dy / radius.value))) * (180 / Math.PI)
snapTo(active.value + Math.round(a / step.value))
return
}
// Release velocity from the last ~100ms of movement.
const recent = samples.filter(s => e.timeStamp - s.t < 100)
const first = recent[0]
const dt = first ? (e.timeStamp - first.t) / 1000 : 0 // paused before release → no fling
const velocity = first && dt > 0 ? (e.clientY - first.y) / dt : 0
const h = itemHeight.value
const POWER = 0.35
// Land exactly on a row.
const snap = (t: number) => Math.max(minOffset.value, Math.min(0, Math.round(t / h) * h))
const target = snap(offset.get() + POWER * velocity)
if (target === offset.get())
return commit()
// Inertia ignores the target keyframe, but motion skips "animations" whose
// keyframes don't change — so pass the landing spot we computed.
anim = animate(offset, target, {
type: 'inertia',
velocity,
power: POWER,
timeConstant: 260,
min: minOffset.value,
max: 0,
bounceStiffness: 380,
bounceDamping: 36,
restDelta: 0.5,
modifyTarget: snap,
onComplete: commit,
})
}
// ── wheel / trackpad ────────────────────────────────────────────────────────
let wheelTimer: ReturnType<typeof setTimeout> | undefined
function onWheel(e: WheelEvent) {
if (props.disabled)
return
const o = offset.get()
const atEdge = (e.deltaY < 0 && o >= 0) || (e.deltaY > 0 && o <= minOffset.value)
if (atEdge)
return // let the page scroll
e.preventDefault()
// Mouse wheels send big discrete deltas: one notch = one row.
if (e.deltaMode !== 0 || Math.abs(e.deltaY) >= 50) {
snapTo((heading ?? nearest(o)) + Math.sign(e.deltaY))
return
}
anim?.stop()
heading = null
offset.set(Math.max(minOffset.value, Math.min(0, o - e.deltaY)))
clearTimeout(wheelTimer)
wheelTimer = setTimeout(() => snapTo(nearest(offset.get())), 120)
}
onBeforeUnmount(() => {
clearTimeout(wheelTimer)
anim?.stop()
})
// ── keyboard ────────────────────────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
const from = heading ?? active.value
const moves: Record<string, number> = {
ArrowUp: from - 1,
ArrowDown: from + 1,
PageUp: from - 5,
PageDown: from + 5,
Home: 0,
End: lastIndex.value,
}
const next = moves[e.key]
if (next === undefined || props.disabled)
return
e.preventDefault()
snapTo(next)
}
</script>
<template>
<div
role="listbox"
:tabindex="props.disabled ? -1 : 0"
:aria-label="props.label"
:aria-disabled="props.disabled || undefined"
:aria-activedescendant="`${uid}-${active}`"
:class="cn(
'relative w-16 shrink-0 cursor-grab touch-none select-none rounded-lg text-lg tabular-nums outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing',
props.disabled && 'pointer-events-none opacity-50',
props.class,
)"
:style="{ height: `${itemHeight * visibleCount}px` }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel="onWheel"
@keydown="onKeydown"
>
<div
v-if="!group"
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-lg bg-muted"
:style="{ height: `${itemHeight}px` }"
/>
<div class="absolute inset-0 [mask-image:linear-gradient(to_bottom,transparent,black_30%,black_70%,transparent)] [perspective:1000px]">
<WheelPickerItem
v-for="(item, i) in items"
:id="`${uid}-${i}`"
:key="item.value"
:index="i"
:label="item.label"
:selected="i === active"
:offset="offset"
:item-height="itemHeight"
:step="step"
:radius="radius"
/>
</div>
</div>
</template>vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
import { WHEEL_GROUP_KEY } from './context'
const props = withDefaults(defineProps<{
/** Row height in px, shared by every wheel inside. */
itemHeight?: number
/** Rows visible at once (odd), shared by every wheel inside. */
visibleCount?: number
/** Accessible name for the whole picker, e.g. "Alarm time". */
label?: string
class?: HTMLAttributes['class']
}>(), {
itemHeight: 36,
visibleCount: 5,
})
provide(WHEEL_GROUP_KEY, { itemHeight: props.itemHeight, visibleCount: props.visibleCount })
</script>
<template>
<div
role="group"
:aria-label="props.label"
:class="cn('relative inline-flex items-center gap-1 px-2', props.class)"
>
<!-- One selection band behind all wheels -->
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-xl bg-muted"
:style="{ height: `${props.itemHeight}px` }"
/>
<slot />
</div>
</template>vue
<script setup lang="ts">
import type { MotionValue } from 'motion-v'
import { motion, useTransform } from 'motion-v'
// One face of the drum. Its 3D position is derived from the wheel's scroll
// offset with motion values, so dragging never re-renders Vue.
const props = defineProps<{
id: string
index: number
label: string
selected: boolean
offset: MotionValue<number>
itemHeight: number
/** Angle between two neighbouring items, in degrees. */
step: number
/** Drum radius in px. */
radius: number
}>()
const DEG = Math.PI / 180
/** Signed angle of this item from the front of the drum, in degrees. */
const angle = useTransform(props.offset, o => (props.index + o / props.itemHeight) * props.step)
const y = useTransform(angle, a => props.radius * Math.sin(Math.max(-90, Math.min(90, a)) * DEG))
const z = useTransform(angle, a => props.radius * (Math.cos(Math.max(-90, Math.min(90, a)) * DEG) - 1))
const rotateX = useTransform(angle, a => -a)
const opacity = useTransform(angle, (a) => {
const items = Math.abs(a) / props.step
return Math.abs(a) >= 90 ? 0 : Math.max(0.1, 1 - items * 0.3)
})
const scale = useTransform(angle, a => 1 - Math.min(1, Math.abs(a) / 90) * 0.08)
</script>
<template>
<motion.div
:id="props.id"
role="option"
:aria-selected="props.selected"
class="absolute inset-x-0 top-1/2 flex items-center justify-center whitespace-nowrap [backface-visibility:hidden]"
:style="{ height: `${props.itemHeight}px`, marginTop: `${-props.itemHeight / 2}px`, y, z, rotateX, opacity, scale }"
>
{{ props.label }}
</motion.div>
</template>ts
import type { InjectionKey } from 'vue'
export type WheelPickerOption = string | number | { value: string | number, label: string }
export interface WheelPickerGroupContext {
itemHeight: number
visibleCount: number
}
/** Provided by <WheelPickerGroup> so several wheels share one selection band. */
export const WHEEL_GROUP_KEY: InjectionKey<WheelPickerGroupContext> = Symbol('vx-wheel-group')ts
export { default as WheelPicker } from './WheelPicker.vue'
export { default as WheelPickerGroup } from './WheelPickerGroup.vue'
export type { WheelPickerOption } from './context'Usage
ts
import { WheelPicker, WheelPickerGroup } from '@/components/ui/wheel-picker'vue
<!-- One wheel -->
<WheelPicker v-model="size" :options="['XS', 'S', 'M', 'L', 'XL']" label="Size" />
<!-- Several wheels sharing one selection band -->
<WheelPickerGroup label="Time">
<WheelPicker v-model="hour" :options="hours" label="Hour" />
<span aria-hidden="true">:</span>
<WheelPicker v-model="minute" :options="minutes" label="Minute" />
</WheelPickerGroup>Options can be plain strings / numbers or { value, label } objects.
API
WheelPicker
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | string | number | — | The selected option's value. Updates when the wheel settles. |
options | (string | number | { value, label })[] | — | The rows. |
label | string | — | Accessible name of the listbox. |
itemHeight | number | 36 | Row height in px (inherited inside a group). |
visibleCount | number | 5 | Rows visible at once, odd (inherited inside a group). |
disabled | boolean | false | Disables interaction. |
Keyboard: ↑ / ↓ one row, PageUp / PageDown five rows, Home / End.
WheelPickerGroup
| Prop | Type | Default | Description |
|---|---|---|---|
itemHeight | number | 36 | Row height shared by every wheel. |
visibleCount | number | 5 | Visible rows shared by every wheel. |
label | string | — | Accessible name of the group. |