Volver al blog

Secciones de contadores de estadísticas animados en React

Publicado el 20 de marzo de 2026·7 min read

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:

  1. Aísla el componente del contador: mantén el useState dentro del componente Counter, no en el padre. Así evitas que toda la cuadrícula de estadísticas se re-renderice en cada frame.
  2. Usa requestAnimationFrame: nunca recurras a setInterval para las animaciones. rAF se sincroniza con el ciclo de pintado del navegador y se pausa automáticamente en las pestañas en segundo plano.
  3. 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

VA

Victor Aubague

Desarrollador y creador de Incubator

Desarrollador full-stack especializado en React, Next.js y TypeScript. Creé Incubator para ayudar a los desarrolladores a lanzar interfaces bonitas más rápido, todos los componentes provienen de proyectos reales de clientes y de código en producción.

LinkedIn

Related articles

Secciones de contadores de estadísticas animados en React