From 5bd060c072e7de5f6f2aad29a0f765f0a245ba25 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 9 Jul 2026 17:10:13 +0530 Subject: [PATCH 1/2] fix: make wheel picker draggable and smooth on touch devices --- components/motion/wheel-picker.tsx | 142 ++++++++++++------ .../previews/motion/wheel-picker.preview.tsx | 3 + 2 files changed, 102 insertions(+), 43 deletions(-) diff --git a/components/motion/wheel-picker.tsx b/components/motion/wheel-picker.tsx index f4381ef..a5a9f88 100644 --- a/components/motion/wheel-picker.tsx +++ b/components/motion/wheel-picker.tsx @@ -117,8 +117,10 @@ export function WheelPicker({ for (const node of Array.from(drum.children)) { const li = node as HTMLLIElement; const i = Number(li.dataset.index); - li.style.visibility = - Math.abs(i - s) > hideBeyond ? "hidden" : "visible"; + const want = Math.abs(i - s) > hideBeyond ? "hidden" : "visible"; + // Write only on change — an unconditional write every frame thrashes + // style recalc and is what made the drag feel draggy on mobile. + if (li.style.visibility !== want) li.style.visibility = want; } } // The band is the SAME drum, clipped to the centre row — driven by the @@ -130,8 +132,8 @@ export function WheelPicker({ for (const node of Array.from(band.children)) { const li = node as HTMLLIElement; const i = Number(li.dataset.index); - li.style.visibility = - Math.abs(i - s) > hideBeyond ? "hidden" : "visible"; + const want = Math.abs(i - s) > hideBeyond ? "hidden" : "visible"; + if (li.style.visibility !== want) li.style.visibility = want; } } }, @@ -221,65 +223,89 @@ export function WheelPicker({ scroll: number; pts: [number, number][]; } | null>(null); - const onPointerDown = useCallback( - (event: PointerEvent) => { - if (disabled || reduce) return; - event.currentTarget.setPointerCapture(event.pointerId); + // Shared drag core, driven by a Y coordinate from either a mouse pointer or a + // native touch. Touch is bound with non-passive listeners in the effect below + // so the move can preventDefault the page scroll — React's synthetic touch + // events are passive and can't, which is why finger-drag did nothing on + // mobile. + const beginDrag = useCallback( + (y: number) => { stop(); setGrabbing(true); drag.current = { - y: event.clientY, + y, scroll: scroll.current, - pts: [[event.clientY, performance.now()]], + pts: [[y, performance.now()]], }; }, - [disabled, reduce, stop], + [stop], ); - const onPointerMove = useCallback( - (event: PointerEvent) => { + const moveDrag = useCallback( + (y: number) => { const d = drag.current; if (!d) return; - let next = d.scroll + (d.y - event.clientY) / itemHeight; + let next = d.scroll + (d.y - y) / itemHeight; if (next < 0) next *= 0.3; else if (next > last) next = last + (next - last) * 0.3; scroll.current = next; paint(next); emit(Math.round(clamp(next, 0, last))); - d.pts.push([event.clientY, performance.now()]); + d.pts.push([y, performance.now()]); if (d.pts.length > 8) d.pts.shift(); }, [itemHeight, last, paint, emit], ); + const endDrag = useCallback(() => { + const d = drag.current; + if (!d) return; + drag.current = null; + setGrabbing(false); + // Average velocity over the last `VELOCITY_WINDOW` ms of movement rather + // than the final two samples — a single noisy frame otherwise makes an + // even flick feel like it caught or slipped. + const pts = d.pts; + let v = 0; + if (pts.length > 1) { + const latest = pts[pts.length - 1]; + let ref = pts[0]; + for (const p of pts) { + if (latest[1] - p[1] <= VELOCITY_WINDOW) { + ref = p; + break; + } + } + const dt = latest[1] - ref[1]; + if (dt > 0) { + const raw = (ref[0] - latest[0]) / itemHeight / dt; + v = clamp(raw, -MAX_VELOCITY, MAX_VELOCITY); + } + } + fling(v); + }, [itemHeight, fling]); + + // Mouse / pen only — touch runs through the native listeners in the effect. + const onPointerDown = useCallback( + (event: PointerEvent) => { + if (disabled || reduce || event.pointerType === "touch") return; + event.currentTarget.setPointerCapture(event.pointerId); + beginDrag(event.clientY); + }, + [disabled, reduce, beginDrag], + ); + const onPointerMove = useCallback( + (event: PointerEvent) => { + if (event.pointerType === "touch") return; + moveDrag(event.clientY); + }, + [moveDrag], + ); const onPointerUp = useCallback( (event: PointerEvent) => { - const d = drag.current; - if (!d) return; + if (event.pointerType === "touch") return; event.currentTarget.releasePointerCapture?.(event.pointerId); - drag.current = null; - setGrabbing(false); - // Average velocity over the last `VELOCITY_WINDOW` ms of movement rather - // than the final two samples — a single noisy frame otherwise makes an - // even flick feel like it caught or slipped. - const pts = d.pts; - let v = 0; - if (pts.length > 1) { - const latest = pts[pts.length - 1]; - let ref = pts[0]; - for (const p of pts) { - if (latest[1] - p[1] <= VELOCITY_WINDOW) { - ref = p; - break; - } - } - const dt = latest[1] - ref[1]; - if (dt > 0) { - const raw = (ref[0] - latest[0]) / itemHeight / dt; - v = clamp(raw, -MAX_VELOCITY, MAX_VELOCITY); - } - } - fling(v); + endDrag(); }, - [itemHeight, fling], + [endDrag], ); const wheelAt = useRef(0); @@ -328,6 +354,36 @@ export function WheelPicker({ useEffect(() => () => cancelAnimationFrame(raf.current), []); + // Native touch listeners, bound non-passively so touchmove can block the page + // from scrolling while the wheel spins. React's synthetic touch handlers are + // passive, so preventDefault there is a no-op and the finger just scrolls the + // page instead of the drum. + useEffect(() => { + const el = container.current; + if (!el || reduce || disabled) return; + const onStart = (e: TouchEvent) => { + const t = e.touches[0]; + if (t) beginDrag(t.clientY); + }; + const onMove = (e: TouchEvent) => { + const t = e.touches[0]; + if (!t || !drag.current) return; + e.preventDefault(); + moveDrag(t.clientY); + }; + const onEnd = () => endDrag(); + el.addEventListener("touchstart", onStart, { passive: true }); + el.addEventListener("touchmove", onMove, { passive: false }); + el.addEventListener("touchend", onEnd); + el.addEventListener("touchcancel", onEnd); + return () => { + el.removeEventListener("touchstart", onStart); + el.removeEventListener("touchmove", onMove); + el.removeEventListener("touchend", onEnd); + el.removeEventListener("touchcancel", onEnd); + }; + }, [reduce, disabled, beginDrag, moveDrag, endDrag]); + const maskFade = "[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]"; @@ -404,7 +460,7 @@ export function WheelPicker({
    {options.map((option, i) => (
  • {options.map((option, i) => (
  • @@ -60,6 +61,7 @@ export function WheelPickerPreview() { value={day} onValueChange={setDay} className="w-14 border-0 bg-transparent" + visibleCount={7} itemHeight={42} aria-label="Day" /> @@ -68,6 +70,7 @@ export function WheelPickerPreview() { value={year} onValueChange={setYear} className="w-20 border-0 bg-transparent" + visibleCount={7} itemHeight={42} aria-label="Year" /> From ecb8bc406b8324716ed7aef5258acdcb118c73c3 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 9 Jul 2026 17:21:14 +0530 Subject: [PATCH 2/2] fix: smooth wheel-picker rAF drag and continuous wheel scroll --- components/motion/wheel-picker.tsx | 82 ++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 22 deletions(-) diff --git a/components/motion/wheel-picker.tsx b/components/motion/wheel-picker.tsx index a5a9f88..c6bc2c6 100644 --- a/components/motion/wheel-picker.tsx +++ b/components/motion/wheel-picker.tsx @@ -9,7 +9,6 @@ import { useMemo, useRef, useState, - type WheelEvent, } from "react"; import { cn } from "@/lib/utils"; @@ -36,7 +35,8 @@ const DEG = Math.PI / 180; const DECELERATION = 0.00042; // rows per ms², how fast a flick bleeds off (lower = freer) const MAX_VELOCITY = 0.18; // rows per ms, caps a hard fling const VELOCITY_WINDOW = 90; // ms of recent drag to average a release velocity over -const WHEEL_THROTTLE = 90; // ms between wheel steps +const WHEEL_SENS = 0.012; // rows per pixel of wheel delta +const WHEEL_SETTLE = 110; // ms of wheel idle before snapping to a row const easeOutCubic = (p: number) => 1 - (1 - p) ** 3; // Overshoots the target by a few percent then settles — the little spring // bounce as a row snaps home. `BACK` sets how far it drifts past. @@ -223,6 +223,11 @@ export function WheelPicker({ scroll: number; pts: [number, number][]; } | null>(null); + // Coalesce touch/pointer moves to one paint per animation frame — raw move + // events fire several times per frame (and off-frame) on high-refresh + // screens, and painting each one is what made the drag feel choppy. + const dragFrame = useRef(0); + const latestY = useRef(0); // Shared drag core, driven by a Y coordinate from either a mouse pointer or a // native touch. Touch is bound with non-passive listeners in the effect below // so the move can preventDefault the page scroll — React's synthetic touch @@ -244,20 +249,33 @@ export function WheelPicker({ (y: number) => { const d = drag.current; if (!d) return; - let next = d.scroll + (d.y - y) / itemHeight; - if (next < 0) next *= 0.3; - else if (next > last) next = last + (next - last) * 0.3; - scroll.current = next; - paint(next); - emit(Math.round(clamp(next, 0, last))); + // Record every sample for an accurate release velocity, but only render + // the newest position once per frame. + latestY.current = y; d.pts.push([y, performance.now()]); if (d.pts.length > 8) d.pts.shift(); + if (dragFrame.current) return; + dragFrame.current = requestAnimationFrame(() => { + dragFrame.current = 0; + const dd = drag.current; + if (!dd) return; + let next = dd.scroll + (dd.y - latestY.current) / itemHeight; + if (next < 0) next *= 0.3; + else if (next > last) next = last + (next - last) * 0.3; + scroll.current = next; + paint(next); + emit(Math.round(clamp(next, 0, last))); + }); }, [itemHeight, last, paint, emit], ); const endDrag = useCallback(() => { const d = drag.current; if (!d) return; + if (dragFrame.current) { + cancelAnimationFrame(dragFrame.current); + dragFrame.current = 0; + } drag.current = null; setGrabbing(false); // Average velocity over the last `VELOCITY_WINDOW` ms of movement rather @@ -308,17 +326,29 @@ export function WheelPicker({ [endDrag], ); - const wheelAt = useRef(0); + // Wheel drives `scroll` continuously — like a drag — then snaps once it goes + // idle. Firing a fresh eased step per notch instead stacks overlapping + // animations that keep interrupting each other, which read as lag. + const wheelSnap = useRef | null>(null); const onWheel = useCallback( - (event: WheelEvent) => { + (event: globalThis.WheelEvent) => { + // Native (non-passive) so preventDefault actually stops the page from + // scrolling behind the wheel — React's synthetic wheel listener is + // passive, so a handler on the element cannot cancel the scroll. if (disabled || reduce) return; event.preventDefault(); - const now = performance.now(); - if (now - wheelAt.current < WHEEL_THROTTLE) return; - wheelAt.current = now; - step(Math.sign(event.deltaY)); + stop(); + const px = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const next = clamp(scroll.current + px * WHEEL_SENS, 0, last); + scroll.current = next; + paint(next); + emit(Math.round(next)); + if (wheelSnap.current) clearTimeout(wheelSnap.current); + wheelSnap.current = setTimeout(() => { + glide(clamp(Math.round(scroll.current), 0, last), 240, easeOutBack); + }, WHEEL_SETTLE); }, - [disabled, reduce, step], + [disabled, reduce, last, paint, emit, stop, glide], ); const onKeyDown = useCallback( @@ -352,12 +382,19 @@ export function WheelPicker({ glide(target, 260); }, [currentValue, indexOf, paint, glide]); - useEffect(() => () => cancelAnimationFrame(raf.current), []); + useEffect( + () => () => { + cancelAnimationFrame(raf.current); + cancelAnimationFrame(dragFrame.current); + if (wheelSnap.current) clearTimeout(wheelSnap.current); + }, + [], + ); - // Native touch listeners, bound non-passively so touchmove can block the page - // from scrolling while the wheel spins. React's synthetic touch handlers are - // passive, so preventDefault there is a no-op and the finger just scrolls the - // page instead of the drum. + // Native touch + wheel listeners, bound non-passively so touchmove and wheel + // can block the page from scrolling while the wheel spins. React's synthetic + // touch/wheel handlers are passive, so preventDefault there is a no-op and the + // gesture scrolls the page instead of driving the drum. useEffect(() => { const el = container.current; if (!el || reduce || disabled) return; @@ -376,13 +413,15 @@ export function WheelPicker({ el.addEventListener("touchmove", onMove, { passive: false }); el.addEventListener("touchend", onEnd); el.addEventListener("touchcancel", onEnd); + el.addEventListener("wheel", onWheel, { passive: false }); return () => { el.removeEventListener("touchstart", onStart); el.removeEventListener("touchmove", onMove); el.removeEventListener("touchend", onEnd); el.removeEventListener("touchcancel", onEnd); + el.removeEventListener("wheel", onWheel); }; - }, [reduce, disabled, beginDrag, moveDrag, endDrag]); + }, [reduce, disabled, beginDrag, moveDrag, endDrag, onWheel]); const maskFade = "[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]"; @@ -446,7 +485,6 @@ export function WheelPicker({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} - onWheel={onWheel} className={cn( "relative touch-none select-none overflow-hidden rounded-2xl border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-foreground/20", grabbing ? "cursor-grabbing" : "cursor-grab",