Skip to content

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.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then 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 ​

PropTypeDefaultDescription
v-modelstring | number—The selected option's value. Updates when the wheel settles.
options(string | number | { value, label })[]—The rows.
labelstring—Accessible name of the listbox.
itemHeightnumber36Row height in px (inherited inside a group).
visibleCountnumber5Rows visible at once, odd (inherited inside a group).
disabledbooleanfalseDisables interaction.

Keyboard: ↑ / ↓ one row, PageUp / PageDown five rows, Home / End.

WheelPickerGroup ​

PropTypeDefaultDescription
itemHeightnumber36Row height shared by every wheel.
visibleCountnumber5Visible rows shared by every wheel.
labelstring—Accessible name of the group.

Designed by Kuldeep Pisda · Released under the MIT License