Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 153 additions & 59 deletions components/motion/wheel-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
useMemo,
useRef,
useState,
type WheelEvent,
} from "react";
import { cn } from "@/lib/utils";

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
},
Expand Down Expand Up @@ -221,78 +223,132 @@ export function WheelPicker({
scroll: number;
pts: [number, number][];
} | null>(null);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled || reduce) return;
event.currentTarget.setPointerCapture(event.pointerId);
// 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
// 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<HTMLDivElement>) => {
const moveDrag = useCallback(
(y: number) => {
const d = drag.current;
if (!d) return;
let next = d.scroll + (d.y - event.clientY) / 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()]);
// 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
// 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<HTMLDivElement>) => {
if (disabled || reduce || event.pointerType === "touch") return;
event.currentTarget.setPointerCapture(event.pointerId);
beginDrag(event.clientY);
},
[disabled, reduce, beginDrag],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
moveDrag(event.clientY);
},
[moveDrag],
);
const onPointerUp = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
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);
// 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<ReturnType<typeof setTimeout> | null>(null);
const onWheel = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
(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(
Expand Down Expand Up @@ -326,7 +382,46 @@ 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 + 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;
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);
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, onWheel]);

const maskFade =
"[mask-image:linear-gradient(to_bottom,transparent,#000_22%,#000_78%,transparent)]";
Expand Down Expand Up @@ -390,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",
Expand All @@ -404,7 +498,7 @@ export function WheelPicker({
<ul
ref={drumRef}
aria-hidden
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d]"
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]"
>
{options.map((option, i) => (
<li
Expand Down Expand Up @@ -432,7 +526,7 @@ export function WheelPicker({
<ul
ref={bandRef}
aria-hidden
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d]"
className="absolute inset-x-0 top-1/2 m-0 h-0 list-none p-0 [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform]"
>
{options.map((option, i) => (
<li
Expand Down
3 changes: 3 additions & 0 deletions components/previews/motion/wheel-picker.preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function WheelPickerPreview() {
value={month}
onValueChange={setMonth}
className="w-32 border-0 bg-transparent"
visibleCount={7}
itemHeight={42}
aria-label="Month"
/>
Expand All @@ -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"
/>
Expand All @@ -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"
/>
Expand Down
Loading