Los números generan confianza más rápido que los párrafos. Cuando un visitante llega a tu página y ve "12.000+ clientes" o "99,9% de disponibilidad" apareciendo poco a poco, el mensaje cala al instante. Las secciones de contadores de estadísticas son uno de los componentes de mayor impacto que puedes añadir a una landing page, y resulta sorprendentemente sencillo construirlas en React. Esta guía cubre todo, desde un contador básico con useEffect hasta animaciones de Framer Motion activadas al hacer scroll con entradas escalonadas.
Contador básico con useEffect
El contador animado más simple usa useEffect y requestAnimationFrame para interpolar desde 0 hasta un valor objetivo. No requiere ninguna dependencia:
"use client";
import { useEffect, useRef, useState } from "react";
interface CounterProps {
target: number;
duration?: number; // ms
prefix?: string;
suffix?: string;
}
export function Counter({ target, duration = 2000, prefix = "", suffix = "" }: CounterProps) {
const [count, setCount] = useState(0);
const startTime = useRef<number | null>(null);
useEffect(() => {
function animate(timestamp: number) {
if (!startTime.current) startTime.current = timestamp;
const elapsed = timestamp - startTime.current;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic for a natural deceleration
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(eased * target));
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
return () => { startTime.current = null; };
}, [target, duration]);
return (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{prefix}{count.toLocaleString()}{suffix}
</span>
);
}
La declaración fontVariantNumeric: "tabular-nums" es fundamental: sin ella, los dígitos con espaciado proporcional hacen que el número tiemble horizontalmente mientras cuenta. Las cifras tabulares garantizan que cada dígito ocupe el mismo ancho.
El ease-out cúbico (1 - Math.pow(1 - progress, 3)) hace que el contador arranque rápido y desacelere al acercarse al objetivo, lo que se siente natural. La interpolación lineal parece robótica.
Contador animado con Framer Motion
Si tu proyecto ya utiliza Framer Motion, puedes aprovechar sus hooks useMotionValue y useTransform para un enfoque más declarativo:
"use client";
import { useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
interface MotionCounterProps {
target: number;
duration?: number;
}
export function MotionCounter({ target, duration = 2 }: MotionCounterProps) {
const motionValue = useMotionValue(0);
const rounded = useTransform(motionValue, (v) => Math.round(v).toLocaleString());
useEffect(() => {
const controls = animate(motionValue, target, {
duration,
ease: [0.16, 1, 0.3, 1],
});
return controls.stop;
}, [motionValue, target, duration]);
return (
<motion.span style={{ fontVariantNumeric: "tabular-nums" }}>
{rounded}
</motion.span>
);
}
Este enfoque resulta más limpio porque Framer Motion gestiona el bucle de animación de forma interna y el hook useTransform actualiza el valor mostrado de manera reactiva. La curva personalizada [0.16, 1, 0.3, 1] coincide con la curva tipo spring que se utiliza en la mayoría de los componentes de Incubator.
Contador activado al hacer scroll
Los contadores de estadísticas deberían empezar a animarse cuando entran en pantalla, no cuando se carga la página. Un contador que termina antes de que el usuario llegue a él desperdicia por completo la animación. Usa la API de Intersection Observer:
"use client";
import { useEffect, useRef, useState } from "react";
export function useInView(threshold = 0.3) {
const ref = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect(); // animate once
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, isInView };
}
Después envuelve tu contador:
export function ScrollCounter({ target, ...props }: CounterProps) {
const { ref, isInView } = useInView();
return (
<div ref={ref}>
{isInView ? <Counter target={target} {...props} /> : <span>0</span>}
</div>
);
}
El observer se desconecta tras la primera intersección, por lo que la animación se dispara exactamente una vez. El valor threshold: 0.3 significa que el 30% del elemento debe estar visible antes de activarse, lo que evita que la animación arranque cuando solo hay 1px a la vista.
Cuadrícula de estadísticas con iconos
Un solo contador rara vez resulta útil por sí mismo. El diseño habitual es una cuadrícula de 3 o 4 columnas de estadísticas, cada una con un icono, un número y una etiqueta:
interface Stat {
icon: React.ReactNode;
value: number;
suffix?: string;
label: string;
}
interface StatsGridProps {
stats: Stat[];
}
export function StatsGrid({ stats }: StatsGridProps) {
const { ref, isInView } = useInView();
return (
<section
ref={ref}
style={{
padding: "var(--section-padding-y) 0",
background: "var(--color-surface)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
display: "grid",
gridTemplateColumns: `repeat(${Math.min(stats.length, 4)}, 1fr)`,
gap: "2rem",
}}
>
{stats.map((stat, i) => (
<div
key={stat.label}
style={{
textAlign: "center",
padding: "2rem",
}}
>
<div style={{ marginBottom: "1rem", color: "var(--color-accent)" }}>
{stat.icon}
</div>
<div style={{ fontSize: "clamp(2rem, 4vw, 3.5rem)", fontWeight: 700, lineHeight: 1.1 }}>
{isInView ? (
<Counter target={stat.value} suffix={stat.suffix} duration={2000 + i * 200} />
) : (
<span>0</span>
)}
</div>
<p style={{
marginTop: "0.5rem",
fontSize: "0.9375rem",
color: "var(--color-foreground-muted)",
}}>
{stat.label}
</p>
</div>
))}
</div>
</section>
);
}
La duration escalonada (2000 + i * 200) hace que cada contador termine ligeramente después del anterior, creando un efecto de onda a lo largo de la cuadrícula. Este detalle sutil consigue que la sección se sienta dinámica sin añadir complejidad.
Para un comportamiento responsive, cambia a una cuadrícula de 2 columnas en tablet y a una sola columna en móvil mediante clamp() de CSS o una media query sobre gridTemplateColumns.
Contador de prueba social
Los contadores de prueba social combinan estadísticas con logos de marca o avatares de usuarios. El patrón consiste en una franja horizontal, colocada a menudo justo debajo del hero, que muestra métricas como "Con la confianza de 4.200+ equipos" junto a un grupo de imágenes de avatar:
<div style={{ display: "flex", alignItems: "center", gap: "1rem", justifyContent: "center" }}>
<div style={{ display: "flex" }}>
{avatars.map((src, i) => (
<img
key={src}
src={src}
alt=""
style={{
width: 36,
height: 36,
borderRadius: "50%",
border: "2px solid var(--color-background)",
marginLeft: i > 0 ? "-10px" : 0,
position: "relative",
zIndex: avatars.length - i,
}}
/>
))}
</div>
<p style={{ fontSize: "0.9375rem", color: "var(--color-foreground-muted)" }}>
Trusted by <strong><Counter target={4200} suffix="+" /></strong> teams
</p>
</div>
Los avatares superpuestos (marginLeft negativo) con zIndex descendente crean el clásico apilado de avatares. El border con el mismo color que el fondo genera una separación nítida entre las caras.
Consideraciones de rendimiento
Los contadores animados provocan re-renderizados frecuentes durante la fase de conteo. Conviene tener presentes tres cosas:
- Aísla el componente del contador: mantén el
useStatedentro del componenteCounter, no en el padre. Así evitas que toda la cuadrícula de estadísticas se re-renderice en cada frame. - Usa
requestAnimationFrame: nunca recurras asetIntervalpara las animaciones.rAFse sincroniza con el ciclo de pintado del navegador y se pausa automáticamente en las pestañas en segundo plano. - Respeta
prefers-reduced-motion: los usuarios que han activado el movimiento reducido deberían ver el número final de inmediato, sin animación.
Secciones de estadísticas listas para usar
El catálogo de estadísticas de Incubator incluye contadores en cuadrícula, barras de prueba social, franjas de logos con números animados y variantes preparadas para modo oscuro, todas activadas al hacer scroll y accesibles desde el primer momento. Para patrones de prueba social más amplios (muros de testimonios, apilados de avatares, insignias de valoración), explora el catálogo de prueba social. Cada sección se entrega con props tipadas en TypeScript y datos de ejemplo, listas para pegar en tu proyecto Next.js.
Relacionado en incubator
- Catálogo de secciones de estadísticas: contadores animados y secciones de métricas listas para usar.
- Explorador de contadores animados: secciones seleccionadas en torno a animaciones de conteo.
- Explorador de animaciones al hacer scroll: secciones que se activan al hacer scroll, como estos contadores.
- Secciones de prueba social: apilados de avatares, insignias de valoración y métricas de confianza.
- Catálogo completo de componentes: explora las 449 secciones copy-paste por tipo.