Picker wheel
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Drag or scroll the drums to set the time, with inertia and snapping.
Code
"use client";
import { animate, motion, useMotionValue, useMotionValueEvent, useTransform, type MotionValue } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { Surface } from "../ui";
const ROW = 2.1; // em
const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1));
const MINUTES = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, "0"));
const PERIOD = ["AM", "PM"];
function Item({ label, i, pos }: { label: string; i: number; pos: MotionValue<number> }) {
const d = useTransform(pos, (p) => i - p);
const rotateX = useTransform(d, (v) => Math.max(-90, Math.min(90, v * -22)));
const y = useTransform(d, (v) => `${Math.sin((Math.max(-4, Math.min(4, v)) * Math.PI) / 8) * ROW * 2.6}em`);
const opacity = useTransform(d, (v) => Math.max(0, 1 - Math.abs(v) * 0.28));
return (
<motion.div
style={{ rotateX, y, opacity }}
className="absolute inset-x-0 top-1/2 -mt-[1.05em] flex h-[2.1em] items-center justify-center text-[1.15em] tabular-nums [backface-visibility:hidden]"
>
{label}
</motion.div>
);
}
function Column({ items, initial, onChange, width }: { items: string[]; initial: number; onChange: (v: string) => void; width: string }) {
const pos = useMotionValue(initial);
const ref = useRef<HTMLDivElement>(null);
const drag = useRef<{ y: number; p: number; t: number; v: number } | null>(null);
const [, force] = useState(0);
const max = items.length - 1;
useMotionValueEvent(pos, "change", (p) => {
const i = Math.round(Math.max(0, Math.min(max, p)));
onChange(items[i]);
});
const settle = (velocity = 0) => {
const projected = pos.get() + velocity * 0.18;
const target = Math.max(0, Math.min(max, Math.round(projected)));
animate(pos, target, { type: "spring", stiffness: 220, damping: 28, velocity });
};
useEffect(() => {
const el = ref.current;
if (!el) return;
let timer = 0;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
pos.stop();
pos.set(Math.max(-0.4, Math.min(max + 0.4, pos.get() + e.deltaY / 60)));
window.clearTimeout(timer);
timer = window.setTimeout(() => settle(0), 90);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => {
el.removeEventListener("wheel", onWheel);
window.clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pos, max]);
const rowPx = () => {
const fs = ref.current ? parseFloat(getComputedStyle(ref.current).fontSize) : 16;
return fs * 1.15 * ROW;
};
return (
<div
ref={ref}
style={{ width }}
className="relative h-full cursor-grab touch-none active:cursor-grabbing [perspective:400px]"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
pos.stop();
drag.current = { y: e.clientY, p: pos.get(), t: performance.now(), v: 0 };
force((n) => n + 1);
}}
onPointerMove={(e) => {
const d = drag.current;
if (!d) return;
const now = performance.now();
let p = d.p - (e.clientY - d.y) / rowPx();
if (p < 0) p = p * 0.35;
if (p > max) p = max + (p - max) * 0.35;
const dt = Math.max(1, now - d.t);
d.v = ((p - pos.get()) / dt) * 1000 * 0.6 + d.v * 0.4;
d.t = now;
pos.set(p);
}}
onPointerUp={() => {
const v = drag.current?.v ?? 0;
drag.current = null;
settle(v);
}}
onPointerCancel={() => {
drag.current = null;
settle(0);
}}
>
{items.map((label, i) => (
<Item key={label} label={label} i={i} pos={pos} />
))}
</div>
);
}
export default function PickerWheel() {
const [h, setH] = useState("9");
const [m, setM] = useState("30");
const [p, setP] = useState("AM");
return (
<Surface>
<div className="flex flex-col items-center gap-[1em]">
<div className="relative h-[11em] w-[15em] overflow-hidden rounded-[1.2em] bg-white shadow-[0_10px_30px_-16px_rgba(0,0,0,0.3)]">
<div className="pointer-events-none absolute inset-x-[0.6em] top-1/2 h-[2.4em] -translate-y-1/2 rounded-[0.7em] bg-[#f1f0ec]" />
<div className="relative flex h-full justify-center px-[0.6em] font-medium text-[#151515]">
<Column items={HOURS} initial={8} onChange={setH} width="30%" />
<Column items={MINUTES} initial={6} onChange={setM} width="30%" />
<Column items={PERIOD} initial={0} onChange={setP} width="30%" />
</div>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(#fff,transparent_30%,transparent_70%,#fff)]" />
</div>
<p className="font-mono text-[0.72em] text-[#8a8984]">
alarm set for <span className="text-[#151515]">{`${h}:${m} ${p}`}</span>
</p>
</div>
</Surface>
);
}
Prompt
Build "Picker wheel", a input animation. What it does: Drag or scroll the drums to set the time, with inertia and snapping. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).