DEV Community

Sinny
Sinny

Posted on

Recreating iOS's drum-roll picker in Vue 3 — inertia, rubber-banding, and the tap-vs-drag problem

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:

vue-month-spinner-picker demo — bottom sheet opens and year/month drum-roll spinners scroll with inertia

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));
});
Enter fullscreen mode Exit fullscreen mode

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
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  1. Normalize to a frame, not a millisecond. (dy / dt) * 16 gives you "pixels per 16ms frame", so the animation loop can just do offset += velocity per requestAnimationFrame tick without unit juggling.
  2. 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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 click after 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 in onUnmounted, 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:

Happy to answer questions about any of the physics/gesture handling in the comments.

Top comments (0)