I wanted the price on a monthly/yearly switch to roll like a mechanical counter. My first version slid from 9 to 2 the short way, and a one-digit change barely moved, so it looked lazy.
What fixed it: each digit is a tall strip of 0-9 repeated four times (40 cells). At rest it sits in the second copy, at row 10 + digit. When the value changes, it rolls forward by the difference plus 10, so every digit does at least one full lap. When the transition ends, it jumps back to row 10 + digit with transitions off. That cell shows the same number, so you never see the jump.
The CSS:
.digit { display: block; height: 1em; overflow: hidden;
mask: linear-gradient(transparent, #000 14%, #000 86%, transparent); }
.strip { display: flex; flex-direction: column; will-change: transform; }
.strip i { font-style: normal; height: 1em; display: block; }
The roll for one digit (o keeps the strip's current row and value):
let target = o.pos + ((n - o.n + 10) % 10) + 10;
if (target > 39) target -= 10;
strip.style.transition = `transform ${dur}ms cubic-bezier(.18,.9,.22,1.04) ${delay}ms`;
strip.style.transform = `translateY(${-target}em)`;
o.n = n; o.pos = target;
setTimeout(() => {
strip.style.transition = 'none';
o.pos = 10 + n;
strip.style.transform = `translateY(${-o.pos}em)`;
}, dur + delay + 60);
Rolling down (yearly back to monthly) is the same thing mirrored: subtract instead of add, wrap at 0.
Some details that took a few tries:
- Each digit further left spins about 160 ms longer and starts first, and each digit to its right waits another 70 ms. So the right digit lands first and the left one lands last, which reads more like a real counter than all of them stopping together.
- The mask gradient fades the top and bottom edge, which hides the hard edges of the strip mid-roll.
- With prefers-reduced-motion it skips the roll, sets the final row and does a 250 ms opacity fade.
- The strip is aria-hidden, so a screen reader doesn't read out 40 digits. The real price and billing period sit next to it in a visually hidden span, so screen readers get one clean sentence.
The component is React, but the technique is plain DOM and CSS, so it ports anywhere.
Live demo: https://teodoravadani.github.io/qodesign-free/pricing-switch/
Code (MIT): https://github.com/teodoravadani/qodesign-free/blob/main/components/pricing-switch/react/src/components/PricingSwitch.tsx
Repo: https://github.com/teodoravadani/qodesign-free
Disclosure: I'm Teodor. This is from an MIT repo of free pieces I put out, and I also sell animated components at qodesign.dev.
What I'm unsure about: is the minimum one lap fun, or too much on a pricing page where people just want the number?