Hay algo satisfactorio en ver un número actualizarse sin saltos: los dígitos se desplazan, los nuevos entran por debajo y los viejos se van por arriba. Vamos a construirlo desde cero, sin librerías especializadas. Solo react, motion y un truco con CSS.
Pulsa los botones. Sube hasta tres y cuatro dígitos para ver qué pasa cuando aparece uno nuevo a la izquierda.
la idea
Un número en pantalla es una secuencia de dígitos. Si cada dígito fuera una columna vertical con los caracteres 0 a 9 apilados, cambiar de valor sería simplemente desplazar esa columna hacia arriba hasta que el dígito correcto quede a la vista. Como una ventana que solo deja ver un dígito, con una cinta detrás que tiene los números del 0 al 9 y se desliza hasta dejar el correcto a la vista.
Eso es el 80% del efecto. El 20% restante es lo que ocurre cuando el número gana o pierde un dígito — pasar de 99 a 100 significa que aparece un nuevo dígito a la izquierda y el resto debería quedarse donde estaba.
un dígito
import { motion } from "motion/react";
function DigitColumn({ digit }: { digit: number }) {
return (
<span
style={{ display: "inline-block", overflow: "hidden", height: "1em" }}
>
<motion.span
style={{ display: "flex", flexDirection: "column" }}
initial={{ y: "0%" }}
animate={{ y: `${-digit * 10}%` }}
transition={{ type: "spring", duration: 0.7, bounce: 0.2 }}
>
{Array.from({ length: 10 }, (_, i) => (
<span key={i} style={{ height: "1em", lineHeight: 1 }}>
{i}
</span>
))}
</motion.span>
</span>
);
}
¿Por qué -digit * 10%? La pila tiene diez celdas de 1em, así que su altura total es 10em. Un porcentaje en translateY se calcula sobre la propia altura del elemento, así que -10% equivale a -1em — exactamente una celda. motion se encarga del resto: cuando digit cambia, la columna se desliza hasta su nueva posición con un pequeño rebote al final, en vez de frenar en seco.
initial + animate da el efecto de rodillo al cargar la página: cada columna parte desde el 0 y se desplaza hasta su dígito final. Sin esa línea, motion daría por hecho que el estado inicial es ya el de destino y no animaría nada en el primer render.
varios dígitos, sin perder la cabeza
Cada columna necesita una etiqueta para que React sepa cuál es cuál entre renders. Lo intuitivo es numerarlas de izquierda a derecha, pero ahí está la trampa: al pasar de 99 a 100 todas se desplazan un puesto y React cree que cada una cambió de valor. La que mostraba 9 intenta animarse a 1, la siguiente de 9 a 0, y la nueva aparece de la nada. El efecto es ruido, no fluidez.
El truco es etiquetar desde la derecha. Las unidades siempre llevan la etiqueta 0, las decenas 1, las centenas 2. Así la etiqueta no cambia aunque crezca el número. Al pasar de 99 a 100:
- la
0(unidades) sigue siendo la misma, su valor pasa de9a0 - la
1(decenas) sigue siendo la misma, su valor pasa de9a0 - la
2(centenas) es nueva — el1que entra por la izquierda
const digits = String(value).split("").map(Number);
const items = digits.map((digit, idx) => ({
key: digits.length - 1 - idx,
digit,
}));
Con etiquetas estables, cada columna conserva su identidad entre renders y solo se anima lo que de verdad cambia.
entrada y salida
Falta animar los dígitos que aparecen y los que desaparecen. Para eso usamos AnimatePresence en mode="popLayout": ese modo saca al elemento del flujo en cuanto inicia su salida, dejando que los demás se reacomoden con layout sin tener que esperarlo.
import { AnimatePresence, motion } from "motion/react";
function NumberFlow({ value }: { value: number }) {
const safe = Math.max(0, Math.floor(value));
const digits = String(safe).split("").map(Number);
const items = digits.map((digit, idx) => ({
key: digits.length - 1 - idx,
digit,
}));
return (
<span style={{ display: "inline-flex", overflow: "hidden" }}>
<AnimatePresence mode="popLayout">
{items.map(({ key, digit }) => (
<motion.span
key={key}
layout
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: "-100%" }}
transition={{ type: "spring", duration: 0.7, bounce: 0.2 }}
>
<DigitColumn digit={digit} />
</motion.span>
))}
</AnimatePresence>
</span>
);
}
layout desliza los dígitos existentes hasta su nueva posición cuando entra uno nuevo a la izquierda. y: "100%" y y: "-100%" definen la dirección: los nuevos entran desde abajo, los que se van se marchan por arriba. Si prefieres que el primer render aparezca instantáneo y solo los cambios posteriores se animen, añade initial={false} a AnimatePresence.
detalles que importan
Cifras tabulares. Si la fuente no es monoespaciada, el ancho del 0 puede no ser el mismo que el del 1, y cada cambio provoca un pequeño baile horizontal. La cura:
font-variant-numeric: tabular-nums;
Reducción de movimiento. No todo el mundo quiere ver animaciones. Respeta prefers-reduced-motion configurándolo a nivel global:
<MotionConfig reducedMotion="user">
<NumberFlow value={n} />
</MotionConfig>
Accesibilidad. Para un lector de pantalla, una pila de dígitos sueltos no es un número. Asegúrate de que el contenedor exponga el valor real:
<span aria-label={String(value)} aria-live="polite">
...
</span>
aria-live="polite" hace que el cambio se anuncie sin interrumpir lo que el usuario esté oyendo.
dónde llevar esto
Este es el esqueleto. A partir de aquí casi cualquier variante cabe:
- Decimales. Repite la misma lógica para la parte después del punto.
- Separadores de miles. Usa
Intl.NumberFormaty trata los caracteres no numéricos como elementos separados con su propia animación. - Negativos. Un signo
−que entra cuando el valor cruza el cero. - Tweens en lugar de springs. Si el efecto se siente demasiado rebotado, prueba
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}para una curva más editorial.
Tres ideas combinadas: una columna por dígito, claves por posición desde la derecha, y AnimatePresence con layout para coreografiar entradas y salidas.