Cómo crear una sección de cifras animadas en React con Framer Motion
Una sección de cifras animadas en React activa una animación de conteo cuando la estadística entra en el viewport, usando useInView de Framer Motion para la detección y un bucle setInterval que incrementa hasta el valor final en ~1500ms. Cada tarjeta de métrica aparece con un fundido y se desliza hacia arriba con un retardo escalonado para que la cuadrícula se lea de izquierda a derecha.
- Stack: React 19 + Framer Motion 11 + Lucide React, ~185 líneas, cero dependencias adicionales.
- Lógica del contador: setInterval en 40 pasos durante 1500ms; useInView con once:true evita que se vuelva a disparar al volver con el scroll.
- Layout: cuadrícula CSS de dos columnas (texto a la izquierda, tarjetas de métricas 2×2 a la derecha), se colapsa en móvil mediante diseño responsive.
- Accesible: fontVariantNumeric: tabular-nums evita el desplazamiento del layout cuando cambian los dígitos; el contenido textual permanece legible sin JS.
- Theming exclusivamente mediante CSS custom properties, compatible con los 7 presets, sin colores hardcodeados.
About Numbers Animated es una sección React con split-layout que combina un título y una descripción a la izquierda con una cuadrícula de métricas clave a la derecha. Cada número cuenta desde cero la primera vez que entra en el viewport, dando a la página un momento de impulso. El patrón encaja en cualquier página de producto o empresa que necesite hacer tangible su escala sin apoyarse en un muro de texto.
Anatomía
La sección externa usa CSS custom properties para el padding y el background-alt. Dentro, una cuadrícula CSS de dos columnas separa el contenido editorial de las métricas. La columna izquierda contiene un eyebrow etiquetado (icono TrendingUp + subtítulo), un h2 y un párrafo. La columna derecha es su propia cuadrícula 2×2 de tarjetas; cada tarjeta muestra el número animado en color de acento arriba y la etiqueta de la métrica debajo, dentro de una tarjeta con borde y esquinas redondeadas con relleno card-background.
Cómo funciona
El subcomponente AnimatedCounter asocia un ref a su span y llama a useInView de Framer Motion con once:true. Cuando isInView pasa a true, un setInterval se dispara cada 37,5ms (1500ms / 40 pasos) y suma value/40 al conteo en curso. Math.floor mantiene los estados intermedios como enteros, y clearInterval detiene la ejecución exactamente en el objetivo. La sección padre anima cada tarjeta de métrica mediante whileInView con un retardo escalonado (i * 0.08s) y una curva de easing expo-out [0.16, 1, 0.3, 1], para que las tarjetas lleguen con una ligera sensación elástica en lugar de un fundido plano.
Cómo crearlo en React
Crea el subcomponente AnimatedCounter
Extrae la lógica de conteo en un pequeño subcomponente. Asocia un ref al span mostrado, luego usa useInView de Framer Motion para detectar cuándo entra en el viewport. Inicia el intervalo solo cuando isInView pasa a true, y límpialo al desmontar.
const ref = useRef<HTMLSpanElement>(null); const isInView = useInView(ref, { once: true }); useEffect(() => { if (!isInView) return; const steps = 40; const increment = value / steps; let current = 0; const timer = setInterval(() => { current += increment; if (current >= value) { setCount(value); clearInterval(timer); } else setCount(Math.floor(current)); }, 1500 / steps); return () => clearInterval(timer); }, [isInView, value]);Construye el split-layout de dos columnas
Envuelve la sección en un div contenedor con una cuadrícula CSS de dos columnas iguales y un gap de 4rem. Coloca tu texto editorial a la izquierda y una cuadrícula 2×2 anidada para las tarjetas de métricas a la derecha. Usa CSS custom properties para los espaciados para que la sección se adapte a todos los presets de tema.
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4rem", alignItems: "center" }}> {/* Left: text */} <motion.div initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}> {/* title + description */} </motion.div> {/* Right: 2x2 metrics */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}> {metrics.map((m, i) => ( <motion.div key={i} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08, ease: [0.16, 1, 0.3, 1] }} viewport={{ once: true }}> <AnimatedCounter value={m.value} suffix={m.suffix} /> <p>{m.label}</p> </motion.div> ))} </div> </div>Estiliza los números con tabular-nums para evitar el desplazamiento del layout
Cuando los dígitos cambian de 0 al objetivo, los caracteres de ancho variable hacen temblar las tarjetas. Define fontVariantNumeric: 'tabular-nums' en el elemento del número para que cada dígito ocupe el mismo espacio horizontal durante toda la animación.
<p style={{ fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 700, color: "var(--color-accent)", fontVariantNumeric: "tabular-nums", }}> <AnimatedCounter value={metric.value} suffix={metric.suffix} /> </p>Pasa las métricas como props para una personalización sencilla
Define una interface Metric con value (number), suffix (string, p. ej. '+' o '%'), y label. Acepta un prop array metrics para que los consumidores puedan inyectar sus propias estadísticas sin tocar las interioridades del componente. Los props por defecto de la implementación usan cuatro valores ilustrativos como fallback.
interface Metric { value: number; suffix: string; label: string; } // Usage <AboutNumbersAnimated sectionTitle="By the numbers" description="We've helped 500+ teams ship faster." metrics={[ { value: 500, suffix: "+", label: "Customers" }, { value: 98, suffix: "%", label: "Satisfaction" }, { value: 40, suffix: "+", label: "Countries" }, { value: 12, suffix: "M", label: "Events/month" }, ]} />
Cuándo usarlo
Recurre a esta sección cuando necesites traducir la escala de una empresa a un formato tangible en una página About o de marketing. Funciona bien en mitad de página después de una intro de equipo o una declaración de misión, dejando que las métricas refuercen la narrativa. Evítala cuando las cifras todavía no impresionan o cuando la página ya está cargada de datos; un bloque de estadísticas escaso y débil se lee peor que ningún bloque. En móvil la cuadrícula de dos columnas se apila, así que verifica que el layout apilado se siga leyendo bien antes de desplegar.
Usado por
- Stripe, Usa contadores de estadísticas animados en su página About para mostrar el volumen de pagos y el alcance entre desarrolladores.
- Linear, Muestra los hitos de la empresa como números activados al scroll en su página About.
- Notion, Destaca el número de usuarios y las estadísticas de workspace con animaciones count-up para reforzar la tracción.
- Vercel, Cifras animadas de despliegues y desarrolladores aparecen al scroll en sus secciones de marketing y about.
FAQ
¿Por qué usar setInterval en lugar de animate() de Framer Motion?
setInterval da control directo sobre el redondeo a entero necesario para mostrar números enteros de forma limpia. animate() de Framer Motion interpola flotantes, lo que exige un clamping adicional y un output transformer personalizado. Para un contador que debe mostrar enteros, setInterval con Math.floor es más sencillo y no añade sobrecarga.
¿Cómo disparo el contador cada vez que entra en el viewport, no solo una vez?
Cambia once: true por once: false en la llamada a useInView, y reinicia también setCount(0) al principio del useEffect. Así se reproduce la animación cada vez que el elemento entra en el viewport. Para la mayoría de páginas de marketing es preferible el comportamiento once:true, ya que los conteos repetidos pueden resultar molestos.
¿Funciona el layout en móvil?
El componente usa una cuadrícula CSS de dos columnas que no se colapsa automáticamente en pantallas pequeñas. Añade una media query o una cuadrícula responsive (gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))') para apilar verticalmente las columnas de texto y métricas en móvil. La cuadrícula 2×2 interior puede quedarse igual o pasar a una sola columna.
¿Cómo añado un valor decimal como 4,8 estrellas?
El tipo del estado count ya es number, quita Math.floor de la línea del incremento y usa count.toFixed(1) en el render. Ajusta el incremento a value / steps y la condición de clearInterval a current >= value como antes.