Baktiyar Ablaikhan

Rolling counter

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Tap or hold plus and minus; each digit rolls to its new value.

Code

"use client";

import { motion, useSpring, useTransform, type MotionValue } from "motion/react";
import { Minus, Plus } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Surface } from "../ui";

function Digit({ place, value }: { place: number; value: MotionValue<number> }) {
  // a column 0–9 that rolls to the digit at this place
  const y = useTransform(value, (v) => {
    const whole = Math.floor(v / place) % 10;
    const frac = Math.min(1, Math.max(0, (v % place) - (place - 1)));
    return `${(-(whole + frac) * 100) / 11}%`;
  });
  return (
    <span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden">
      <motion.span className="absolute left-0 top-0 flex flex-col" style={{ y }}>
        {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n, k) => (
          <span key={k} className="flex h-[1em] items-center justify-center">
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

export default function RollingCounter() {
  const [n, setN] = useState(95);
  const spring = useSpring(95, { stiffness: 140, damping: 20 });
  const hold = useRef(0);

  useEffect(() => {
    spring.set(n);
  }, [n, spring]);
  useEffect(() => () => window.clearInterval(hold.current), []);

  const step = (d: number) => setN((v) => Math.max(0, Math.min(999, v + d)));
  const start = (d: number) => {
    step(d);
    window.clearInterval(hold.current);
    let speed = 0;
    hold.current = window.setInterval(() => {
      speed++;
      if (speed > 3) step(d * (speed > 12 ? 5 : 1));
    }, 110);
  };
  const stop = () => window.clearInterval(hold.current);

  const btn =
    "grid h-[2.6em] w-[2.6em] touch-none place-items-center rounded-[0.7em] bg-[#f1f0ec] text-[#151515] transition-colors hover:bg-[#e4e3de] disabled:opacity-40";

  return (
    <Surface>
      <div className="flex flex-col items-center gap-[1em]">
        <div className="flex items-center gap-[0.6em] rounded-[1em] bg-white p-[0.45em] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.35),0_0_0_1px_rgba(0,0,0,0.04)]">
          <motion.button type="button" aria-label="Decrease" whileTap={{ scale: 0.88 }} className={btn} onPointerDown={() => start(-1)} onPointerUp={stop} onPointerLeave={stop} onPointerCancel={stop}>
            <Minus className="h-[1em] w-[1em]" />
          </motion.button>
          <div className="flex w-[4.4em] justify-center text-[2.4em] font-medium leading-none tabular-nums">
            {n >= 100 && <Digit place={100} value={spring} />}
            {n >= 10 && <Digit place={10} value={spring} />}
            <Digit place={1} value={spring} />
          </div>
          <motion.button type="button" aria-label="Increase" whileTap={{ scale: 0.88 }} className={btn} onPointerDown={() => start(1)} onPointerUp={stop} onPointerLeave={stop} onPointerCancel={stop}>
            <Plus className="h-[1em] w-[1em]" />
          </motion.button>
        </div>
        <p className="font-mono text-[0.7em] text-[#8a8984]">tap or hold to count faster</p>
      </div>
    </Surface>
  );
}
Prompt

Build "Rolling counter", a input animation. What it does: Tap or hold plus and minus; each digit rolls to its new value. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using lucide-react, 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).