Baktiyar Ablaikhan

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).