Slot-machine counter
Each digit its own sprung reel, rolling to a new value like an odometer
npx shadcn@latest add https://ethankd.com/r/digit-roller.json"use client";
import { useEffect, useRef } from "react";
import { useInView, useSpring, useTransform, motion } from "motion/react";
const SLOT_EM = 1.15; // digit slot height in em
function Digit({ target }: { target: number }) {
const spring = useSpring(0, { stiffness: 72, damping: 13, mass: 0.75 });
useEffect(() => {
spring.set(target);
}, [spring, target]);
const y = useTransform(spring, (v) => `${-v * SLOT_EM}em`);
return (
<span
className="relative inline-block overflow-hidden align-top"
style={{ height: `${SLOT_EM}em`, width: "1ch" }}
>
<motion.span style={{ y }} className="absolute left-0 top-0 w-full" aria-hidden>
{Array.from({ length: 10 }, (_, i) => (
<span
key={i}
className="block text-center"
style={{ height: `${SLOT_EM}em`, lineHeight: `${SLOT_EM}em` }}
>
{i}
</span>
))}
</motion.span>
</span>
);
}
/**
* Slot-machine digit counter.
*
* Each digit is its own vertical reel of 0-9, sprung to the target value
* independently — so a jump from 08 to 42 rolls the tens digit further
* than the ones digit, the way a real odometer would. Starts counting once
* scrolled into view.
*/
export function DigitRoller({ target, suffix = "" }: { target: number; suffix?: string }) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, margin: "-10% 0px" });
const digits = String(target).split("").map(Number);
return (
<span ref={ref} className="inline-flex items-center tabular-nums" role="text" aria-label={`${target}${suffix}`}>
<span aria-hidden className="inline-flex items-center">
{digits.map((digit, i) => (
<Digit key={i} target={inView ? digit : 0} />
))}
{suffix && (
<span className="inline-block" style={{ lineHeight: `${SLOT_EM}em` }}>
{suffix}
</span>
)}
</span>
</span>
);
}
Playground
<DigitRoller target={128} suffix="+" />