Animate Digits

Per-digit blur-slide animation with direction awareness — only the digits that change animate, sliding up when increasing and down when decreasing.

Installation

CLI installation is not available for Pro components. I'm on it.

File Structure

animate-digits.tsx

Usage

import { AnimateDigits } from "@/components/unlumen-ui/animate-digits";

export default function Example() {
  return <AnimateDigits value="05:00" className="text-6xl font-bold" />;
}

API Reference

value
string

The string to display. Each digit that changes between renders animates independently. Non-digit characters (e.g. `:`, `.`) are rendered statically.

gap?
number
2

Spacing in pixels between characters.

className?
string

Additional classes applied to the outer flex container.

digitClassName?
string

Additional classes applied to each character wrapper.

enterStiffness?
number
150

Spring stiffness for the entering digit.

enterDamping?
number
10

Spring damping for the entering digit.

exitStiffness?
number
150

Spring stiffness for the exiting digit.

exitDamping?
number
15

Spring damping for the exiting digit.

direction?
"dynamic" | "up" | "down"
"dynamic"

`dynamic` infers direction from the digit value (up if increasing, down if decreasing). `up` and `down` force a fixed direction regardless of the value.

enterY?
number
32

Vertical offset in pixels from which the entering digit slides in.

enterBlur?
number
52

Blur amount in pixels applied to the entering digit at the start of its animation.

enterScale?
number
0.7

Scale applied to the entering digit at the start of its animation.

Notes

  • Only digits (0–9) animate. Separator characters like :, ., or / are rendered as plain <span> elements and never trigger an animation.
  • Direction is inferred per-digit when direction="dynamic": a digit going from 3 to 7 slides up; 7 to 3 slides down. Force a fixed direction with "up" or "down".
  • Each DigitCell maintains an exit queue (capped at 3 items) so rapid changes stack their outgoing digits instead of replacing them.
  • The enter animation uses Motion useSpring — the digit snaps to its off-screen position via jump() (y, opacity, scale, blur) then springs to its resting values. The exit uses a motion.span with a standard animate transition.

Known limitation — rapid-fire interruptions

When a new value arrives while an enter animation is already in flight, the current implementation calls jump() again, which teleports the spring to the off-screen start position and resets its velocity. This causes a visible cut rather than a smooth continuation.

The ideal behavior would be: on interruption, spring from the current position and velocity toward the target — no cut, no hard reset.

What was attempted: replacing useSpring + jump() with useMotionValue + Motion's imperative animate(), which accepts a from parameter and can be interrupted without resetting velocity. The approach was sound in theory, but distinguishing "first enter" (needs from off-screen) from "interrupted enter" (needs to continue from current position) proved unreliable in practice:

  • A boolean flag (isAnimatingRef) was tried, but animate().then() never resolves when the animation is interrupted mid-flight, so the flag stayed true permanently after the first interruption — breaking all subsequent enter animations.
  • Reading the live motion values at trigger time (y.get() < 0.5, etc.) was tried as a proxy for "is the digit at rest?", but the threshold logic was fragile and produced inconsistent results across different spring configurations.

What would make it work: Motion's animate() imperative API does preserve velocity across interruptions when called without from. The missing piece is a reliable way to know whether the digit is entering for the first time (so from should be set to the off-screen position) versus being interrupted mid-animation (so no from should be passed). A clean solution could use useAnimationFrame to poll velocity, or hook into Motion's internal animation state — neither of which has a stable public API today.

Credits

Built by leo.

Keep in mind

Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.