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