Retour au catalogue

About Counter Milestones

Section about avec compteurs animes et timeline de jalons connectes.

aboutmedium Both Responsive a11y
corporateboldsaasagencyuniversalstacked
Theme

Cómo crear un contador animado y una timeline de hitos en React

Un contador animado en React dispara un bucle setInterval en cuanto el elemento entra en el viewport, detectado mediante el hook useInView de Framer Motion, incrementando un state local hasta alcanzar el valor objetivo. Combínalo con una timeline de hitos alternada construida con CSS Grid para obtener una sección about completa.

  • Stack: React 18 + Framer Motion 11 + Tailwind v4, ~95 líneas, cero dependencias adicionales.
  • Disparador de la animación: useInView de Framer Motion con once:true, el contador se activa exactamente una vez al entrar en el viewport.
  • El paso del contador es adaptativo: Math.floor(value / 60) garantiza que todos los contadores terminen en aproximadamente el mismo tiempo sin importar su magnitud.
  • Accesible: los contadores usan tabular-nums para un ancho de columna estable; todo el texto es contenido DOM real, no canvas.
  • La timeline alterna izquierda/derecha en pantallas md+ usando el orden de columnas de CSS Grid; en móvil se reduce a una sola columna alineada a la izquierda.

About Counter Milestones reúne dos patrones habituales de las páginas about en una sección cohesionada: una fila de números que se animan al hacer scroll y una timeline vertical de hitos de la empresa dispuestos en una cuadrícula alternada de dos columnas. El resultado transmite credibilidad mediante datos a la vez que cuenta la historia de la marca de forma cronológica.

Anatomía

La sección tiene tres bloques apilados dentro de un contenedor max-w-6xl. Arriba, un header centrado con un badge opcional en span, un h2 y un párrafo de subtítulo, todo apareciendo con fade-in mediante un único motion.div whileInView. Debajo, una cuadrícula de 2 columnas (móvil) a 4 columnas (escritorio) renderiza un AnimatedCounter por cada dato. El último bloque es la timeline de hitos: un contenedor relative que aloja un div de 1px posicionado en absoluto como línea vertical, centrado con left-1/2 en pantallas md y desplazado a la izquierda en móvil, con las tarjetas apareciendo en fundido de forma escalonada mediante whileInView en cada elemento.

Cómo funciona

Cada AnimatedCounter es un componente aislado que asigna una ref a su div contenedor y la pasa a useInView. Cuando isInView cambia a true, un useEffect arranca un setInterval que incrementa un state local display en Math.max(1, Math.floor(value / 60)) cada 20ms. Cuando display alcanza el valor objetivo, el intervalo se detiene. La función de cleanup del return del useEffect garantiza que el intervalo siempre se cancele si el componente se desmonta. Las entradas de la timeline usan whileInView con un retardo de i * 0.1s para escalonar los fundidos sin ninguna orquestación manual.

Cómo crearlo en React

  1. Construye el subcomponente AnimatedCounter

    Crea un componente que acepte value, suffix y label. Asigna una ref al div exterior, pásala a useInView con once:true y luego arranca el intervalo dentro de un useEffect que dependa de [isInView, value]. Devuelve siempre el cleanup para evitar fugas de memoria.

    const ref = useRef<HTMLDivElement>(null);
    const isInView = useInView(ref, { once: true });
    const [display, setDisplay] = useState(0);
    
    useEffect(() => {
      if (!isInView) return;
      const step = Math.max(1, Math.floor(value / 60));
      const timer = setInterval(() => {
        setDisplay(prev => {
          if (prev + step >= value) { clearInterval(timer); return value; }
          return prev + step;
        });
      }, 20);
      return () => clearInterval(timer);
    }, [isInView, value]);
  2. Dispón la cuadrícula de contadores

    Envuelve los contadores en un CSS Grid con grid-cols-2 en móvil y grid-cols-4 en md. Recorre tu array counters y renderiza un AnimatedCounter por cada entrada. Añade tabular-nums al párrafo del número para que los dígitos no cambien de ancho mientras se cuentan.

    <div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-20">
      {counters.map((c, i) => <AnimatedCounter key={i} {...c} />)}
    </div>
  3. Construye la timeline de hitos alternada

    Coloca un contenedor relative alrededor de tu lista de hitos. Dentro, renderiza un div de 1px en absoluto como línea vertical, centrado con left-1/2 en escritorio. Cada hito es una cuadrícula de dos columnas; los índices pares ponen el texto a la izquierda (text-right, pr-12) y los impares lo pasan a la columna derecha con order-2. El punto conector está en absoluto, left-1/2, con un borde en el color accent.

    <div className="relative">
      <div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-px"
           style={{ backgroundColor: "var(--color-border)" }} />
      {milestones.map((ms, i) => (
        <motion.div
          key={i}
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-60px" }}
          transition={{ delay: i * 0.1, duration: 0.5 }}
          className="relative grid grid-cols-1 md:grid-cols-2 gap-8"
        >
          <div className={i % 2 === 0 ? "md:text-right md:pr-12" : "md:order-2 md:pl-12"}>
            <span className="text-xs font-bold">{ms.year}</span>
            <h3 className="mt-1 text-lg font-semibold">{ms.title}</h3>
          </div>
        </motion.div>
      ))}
    </div>
  4. Estiliza con tokens CSS, no con colores fijos

    Cada referencia de color del componente usa una propiedad CSS personalizada: --color-accent para los números y las etiquetas de año, --color-foreground para los títulos, --color-foreground-muted para el cuerpo de texto y --color-border para la línea de la timeline y el borde del punto conector. Así la sección funciona desde el primer momento con los 7 presets de tema sin cambiar ni una sola prop.

Cuándo usarlo

Usa esta sección en las páginas about de empresas, en sitios de productos SaaS y en portfolios de agencias donde importa la prueba social mediante números. Funciona bien en mitad de una página de scroll largo, colocada después de la presentación del equipo y antes del CTA. Evítala si tienes menos de tres datos relevantes, una cuadrícula de contadores medio vacía se ve pobre. En sitios con una historia de empresa corta, sustituye los hitos por lanzamientos de producto o de funcionalidades para mantener la timeline densa.

Usado por

  • Linear, Usa contadores de métricas y una timeline de hitos de producto en su página about para anclar la marca en una tracción medible.
  • Stripe, Muestra cifras de crecimiento animadas junto a una timeline de la historia de la empresa, combinando señales de confianza y narrativa en un solo scroll.
  • Vercel, Combina estadísticas clave de despliegue con hitos de producto cronológicos para mostrar la escala y el impulso en la misma página.
  • Notion, Combina hitos de número de usuarios con una sección de historia de la empresa, usando contadores animados como señal de confianza principal.

FAQ

¿Por qué usar setInterval en lugar de la animación de Framer Motion para el contador?

setInterval ofrece control directo sobre el state de visualización numérica, lo que facilita renderizar el entero exacto en cada frame, añadir un suffix y detenerse con precisión en el valor objetivo. La API animate de Framer Motion encaja mejor con las transformaciones DOM y la opacidad; controlar un contador de React manualmente mediante un intervalo mantiene la lógica sencilla.

¿Cómo hago que todos los contadores terminen al mismo tiempo?

La fórmula de paso Math.max(1, Math.floor(value / 60)) escala el incremento al valor objetivo, así que un contador que va a 10000 avanza ~166 por tick mientras que uno que va a 50 avanza 1 por tick, ambos alcanzan su objetivo en unos 60 ticks de 20ms cada uno, lo que supone alrededor de 1,2 segundos en total.

¿Puedo añadir un icono o una ilustración a cada hito?

Sí. El div del punto conector (absoluto, left-1/2) puede sustituirse por un círculo más grande que contenga un icono de lucide-react. Mantenlo lo bastante pequeño (32-40px) para que no descoloque las columnas de texto, el punto w-3 h-3 ya está posicionado en absoluto fuera del flujo.

¿El contador se vuelve a animar cuando el usuario sube de nuevo?

No. useInView se llama con once:true, así que se dispara exactamente una vez cuando el elemento entra por primera vez en el viewport y nunca más. Quita once:true si quieres que vuelva a reproducirse en cada entrada al viewport.

"use client";

import React, { useEffect, useState, useRef } from "react";
import { motion, useInView } from "framer-motion";

interface Counter {
  value: number;
  suffix?: string;
  label: string;
}

interface Milestone {
  year: string;
  title: string;
  description: string;
}

interface AboutCounterMilestonesProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  counters?: Counter[];

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reseñas

Sección React contadores animados + timeline, Código y