Every native iOS app gets the drum-roll date picker for free: flick a wheel, watch it coast with momentum, feel it bounce at the edges, and it always lands crisply on a value. On the web, you get <input type="month"> — which Safari on desktop doesn't even render as a picker.
I needed a month picker (not a full date picker — think subscription billing months, report periods, statements) for a Vue 3 app, and everything I found was either a full calendar with the day part amputated or looked nothing like what mobile users expect. So I built one, and this post is about the three pieces that were more interesting than expected: inertia scrolling, rubber-band overscroll, and distinguishing a tap from a drag.
The result is vue-month-spinner-picker — zero dependencies beyond Vue 3. Here's what we're building:
The core model: one offset, everything derived
Each wheel is a plain translateY transform driven by a single number, currentOffset. The selected index is derived from the offset, not stored separately:
const selectedIndex = computed(() => {
const idx = Math.round(-currentOffset.value / itemHeight);
return Math.max(0, Math.min(idx, items.value.length - 1));
});
This one decision kills a whole class of sync bugs. There is no "the wheel shows March but state says April" — whatever the wheel physically shows is the state. Snapping just means rounding the offset to the nearest multiple of itemHeight.
One performance note: the drag bookkeeping (startY, lastY, velocity, isDragging…) is deliberately not reactive. Only currentOffset is a ref, because it's the only thing the template needs. Touch-move fires a lot; you don't want Vue's reactivity churning on every intermediate value.
Inertia: measure velocity while dragging, spend it after release
The iOS feel comes from the wheel continuing after your finger leaves. That means during the drag you're not just moving the wheel — you're continuously measuring how fast the finger moves:
function onTouchMove(e: TouchEvent) {
const y = e.touches[0].clientY;
const dt = now - lastTime;
if (dt > 0) {
velocity = ((y - lastY) / dt) * 16; // px per ~60fps frame
}
// ...
}
Two details matter here:
-
Normalize to a frame, not a millisecond.
(dy / dt) * 16gives you "pixels per 16ms frame", so the animation loop can just dooffset += velocityperrequestAnimationFrametick without unit juggling. - Only the last movement counts. Velocity is recomputed on every move event, so a fast flick that ends slow (finger decelerates before lifting) correctly produces little inertia. This is exactly how it feels on iOS — you can "catch" the wheel.
On release, if the velocity is above a small threshold, spend it with exponential friction:
function startInertia() {
const friction = 0.95;
const minVelocity = 0.5;
function animate() {
velocity *= friction;
if (Math.abs(velocity) < minVelocity) {
snapToNearest();
return;
}
currentOffset.value += velocity;
animationId = requestAnimationFrame(animate);
}
animationId = requestAnimationFrame(animate);
}
0.95 per frame sounds gentle but decays fast: a flick at 40px/frame is down to ~5px/frame in 40 frames (~0.7s). When it drops below minVelocity, we don't just stop — we snap to the nearest item, so the wheel never rests between two values. Tuning these two constants is the feel of the component; there's no way around trying values on a real phone.
The other subtle bit: touchstart calls cancelAnimation() first. Grabbing a spinning wheel must stop it dead — if you forget this, the user fights a running rAF loop and the wheel stutters.
Rubber-banding: resistance is just multiplication
iOS lets you drag past the ends, but with resistance, and it springs back. The resistance part turns out to be embarrassingly simple — past the boundary, movement counts at 30%:
function applyDragResistance(offset: number): number {
const maxOffset = 0;
const minOffset = -(items.value.length - 1) * itemHeight;
if (offset > maxOffset) return maxOffset + (offset - maxOffset) * 0.3;
if (offset < minOffset) return minOffset + (offset - minOffset) * 0.3;
return offset;
}
That's it. No spring physics during the drag — just a linear damp. The "spring back" is free too: when the finger lifts, snapToNearest() clamps to a real index, and the CSS-transformed wheel animates home. During inertia, the boundary check is stricter: hit the edge while coasting and it clamps + snaps immediately rather than bouncing (month pickers don't need the full bounce — the resistance while dragging carries the tactile message).
The tap-vs-drag problem
This is the one that produces real-world bug reports. Items on the wheel should be tappable (tap "October", get October). But a drag that starts and ends on the same item also fires a click on it. Without care, every flick ends with the wheel jumping to whatever item happened to be under the finger.
The fix is a threshold plus one flag:
const DRAG_THRESHOLD = 5; // px
// in move handler:
if (Math.abs(y - startY) > DRAG_THRESHOLD) {
didDrag = true;
}
// in click handler:
function onItemClick(index: number) {
if (didDrag) return; // this click is the tail end of a drag
// ...select the item
}
5px is the sweet spot I landed on: small enough that no intentional drag ever registers as a tap, large enough that a slightly-wobbly thumb press still counts as a tap. (Testing this deterministically required fake timers — a drag-then-click sequence is timing-dependent by nature.)
Desktop needed its own variant of care: mouse dragging attaches mousemove/mouseup to document, not the element — otherwise dragging fast enough to leave the element mid-drag leaves the wheel stuck to your cursor forever. And those document listeners must be removed in onUnmounted, including the case where the component unmounts mid-drag.
Mouse wheel: debounce the snap, not the scroll
Trackpads emit dozens of wheel events per gesture. Applying deltaY directly feels right, but snapping after each event makes the wheel vibrate. The trick is to let the offset run free and only snap when input goes quiet:
function onWheel(e: WheelEvent) {
e.preventDefault();
currentOffset.value = clampOffset(currentOffset.value - e.deltaY);
clearTimeout(wheelSnapTimer);
wheelSnapTimer = setTimeout(snapToNearest, 150);
}
150ms after the last wheel event, snap. Scrolling feels continuous; the landing is still crisp.
Disabled items: snap must search outward
Min/max month constraints mean some items on the wheel are visible but disabled (you can see January, but it's before minMonth). Snapping to the nearest item isn't enough — the nearest item might be disabled. So the snap searches outward for the nearest enabled index:
function findNearestEnabledIndex(targetIdx: number): number {
if (!items.value[clamped].disabled) return clamped;
for (let d = 1; d < items.value.length; d++) {
if (clamped - d >= 0 && !items.value[clamped - d].disabled) return clamped - d;
if (clamped + d < items.value.length && !items.value[clamped + d].disabled) return clamped + d;
}
return clamped;
}
Flick hard into the disabled zone and the wheel coasts, hits it, and settles on the last valid month. No error state, no invalid selection possible — the physics enforce the constraint.
What I'd tell past me
- Derive selection from the offset. One source of truth for a physical UI element saves you from every sync bug at once.
- The feel lives in ~4 constants (friction, min velocity, resistance factor, drag threshold) — budget real device time for tuning them, not just DevTools.
-
The
clickafter a drag is not your click. Handle it explicitly or it will handle you. -
document-level listeners + component lifecycles are where desktop drag implementations go to leak. Clean up inonUnmounted, including mid-drag.
The whole thing is ~300 lines in a composable (useSpinner.ts), tested with Vitest (114 tests, including property-based tests with fast-check for the value/format utilities).
If you need a month picker — or just want to poke at the physics:
- 📦
npm install vue-month-spinner-picker(npm) - 📺 Live demo · StackBlitz playground
- ⭐ GitHub — issues and PRs welcome (i18n locales are an easy first contribution)
Happy to answer questions about any of the physics/gesture handling in the comments.

Top comments (0)