Text that only moves where it changed
When $9.99 becomes $10.99, which digit is which?
A number that changes is information. If the whole thing flashes, you have to read it again to see what happened. If only the digits that changed move, your eye goes straight to the difference. That’s what Smart Animate Text does, and it comes down to one question: when the value changes, which character is the same character as before?
Count from the right
React decides what is “the same” by key. Key from the left and $9.99 going to $10.99 shifts every position, so the cents animate even though they didn’t change. Number digits from the right, like place values, and the cents stay the cents.
Keyed from the left
4 characters animate
Keyed from the right
2 characters animate
.reverse()
.map(({ char, index, isDigit, isLetter }) => {
if (isDigit) return { key: `digit-${digitPlace++}`, char, isAnimatable: true };
if (isLetter) return { key: `letter-${index}`, char, isAnimatable: true };
// a symbol is only "the second comma from the right"
return { key: `symbol-${char}-${place}`, char, isAnimatable: false };
})
.reverse();Symbols never animate. A comma or a dollar sign flickering says something changed that didn’t. They only slide, when their neighbors make room.
It tells you which way
The direction comes from the value. If the number went up, new digits rise from below. If it went down, they fall from above. It’s one comparison, and it’s why this reads as counting and not as swapping: you know a price went down before you’ve read it.
The delay only counts characters that changed. If the last two digits move, they start right away instead of waiting for six others that aren’t doing anything. A stagger that makes you wait for nothing is decoration.
const enterDirection = numericValue < previousNumericValue ? "down" : "up";One spring, and one glide
A digit’s position and size arrive on a slightly underdamped spring, stiffness 370 and damping 24, so they overshoot. The characters can be lively because the row around them stays calm. Move the numbers yourself: there’s a narrow band where it feels alive, and it’s easy to fall out of it on either side.
The row has no spring at all. It borrows the glide from the scramble on this site’s titles: each character sits in a box as wide as what it shows, and when a box’s position changes, the box is put back where it was and the gap shrinks a little every frame. A wide 0 replacing a thin 1 just pushes its neighbors aside, with no scaling and no distorted letters.
const xs = cells.map((cell) => cell.offsetLeft); // all reads first…
cells.forEach((cell, i) => {
// …then all writes
if (xs[i] !== box.x) box.shift -= xs[i] - box.x;
box.x = xs[i];
if (decay) box.shift *= 1 - GLIDE;
cell.style.transform = `translateX(${box.shift.toFixed(2)}px)`;
});Two bugs I shipped
The blur used to be on that same bouncy spring. A spring overshoots, so the radius went below zero. A negative blur is invalid CSS, and the browser drops the whole filter: for a few frames the digit flashed sharp in the middle of its blur. Blur and opacity now have their own critically damped spring, six times stiffer, which arrives just as fast without ever going past.
const fadeStiffness = enterStiffness * 6;
const fadeConfig = {
stiffness: fadeStiffness,
damping: 2 * Math.sqrt(fadeStiffness), // arrives, never overshoots
};
const blur = useSpring(0, fadeConfig);
const filter = useTransform(blur, (v) =>
v < 0.05 ? "none" : `blur(${v}px)`,
);The second one left holes: $1, 99.00. A digit with a stagger waited on a timer to appear, and React’s development mode runs every effect twice. The first cleanup canceled the timer, the second run saw nothing new, and the digit stayed invisible. Now a digit remembers it’s still waiting, and starts its timer again.