Retour au catalogue

About Counter Milestones

Section about avec compteurs animes et timeline de jalons connectes.

aboutmedium Both Responsive a11y
corporateboldsaasagencyuniversalstacked
Theme

How to build an animated counter and milestone timeline in React

An animated counter in React fires a setInterval loop once the element enters the viewport, detected via Framer Motion's useInView hook, incrementing a local state value until it reaches the target. Pair it with a vertically alternating milestone timeline built with CSS Grid to get a complete about section.

  • Stack: React 18 + Framer Motion 11 + Tailwind v4, ~95 lines, zero extra dependencies.
  • Animation trigger: Framer Motion useInView with once:true, fires the counter exactly once when it first scrolls into view.
  • Counter step is adaptive: Math.floor(value / 60) ensures all counters finish in roughly the same time regardless of magnitude.
  • Accessible: counters use tabular-nums for stable column width; all text is real DOM content, not canvas.
  • The timeline alternates left/right on md+ screens using CSS Grid column ordering; it collapses to a single left-aligned column on mobile.

About Counter Milestones combines two common about-page patterns into one cohesive section: a row of animated numbers that count up on scroll, and a vertical timeline of company milestones laid out in an alternating two-column grid. The result communicates credibility with data points while telling the brand story chronologically.

Anatomy

The section has three stacked blocks inside a max-w-6xl container. At the top, a centered header with an optional badge span, an h2, and a subtitle paragraph, all fading in via a single whileInView motion.div. Below it, a 2-col (mobile) to 4-col (desktop) grid renders one AnimatedCounter per data point. The last block is the milestone timeline: a relative container holding a 1px vertical line absolutely centered on md screens and offset to the left on mobile, with milestone cards fading in staggered via whileInView on each item.

How it works

Each AnimatedCounter is an isolated component that attaches a ref to its wrapper div and passes it to useInView. When isInView flips to true, a useEffect starts a setInterval that increments a local display state by Math.max(1, Math.floor(value / 60)) every 20ms. When display reaches the target value, the interval is cleared. The cleanup function in the useEffect return ensures the interval is always cancelled if the component unmounts. The timeline entries use whileInView with a delay of i * 0.1s to stagger the fade-ins without any manual orchestration.

How to build it in React

  1. Build the AnimatedCounter sub-component

    Create a component that accepts value, suffix, and label. Attach a ref to the outer div, pass it to useInView with once:true, then start the interval inside a useEffect that depends on [isInView, value]. Always return the cleanup to avoid memory leaks.

    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. Lay out the counters grid

    Wrap the counters in a CSS Grid with grid-cols-2 on mobile and grid-cols-4 on md. Map over your counters array and render one AnimatedCounter per entry. Add tabular-nums to the number paragraph so digits don't shift width while counting.

    <div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-20">
      {counters.map((c, i) => <AnimatedCounter key={i} {...c} />)}
    </div>
  3. Build the alternating milestone timeline

    Place a relative container around your milestones list. Inside, render an absolutely positioned 1px div as the vertical line, centered with left-1/2 on desktop. Each milestone is a two-column grid; even indices put the text on the left (text-right, pr-12), odd indices swap it to the right column with order-2. The connector dot is absolute, left-1/2, with a border in the accent color.

    <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. Style with CSS tokens, not hardcoded colors

    Every color reference in the component uses a CSS custom property: --color-accent for numbers and year labels, --color-foreground for headings, --color-foreground-muted for body text, and --color-border for the timeline line and connector dot border. This makes the section work out of the box with all 7 theme presets without a single prop change.

When to use it

Use this section on company about pages, SaaS product sites, and agency portfolios where social proof through numbers matters. It works well in the middle of a long-scroll page, placed after the team intro and before the CTA. Skip it when you have fewer than three meaningful data points, a half-empty counter grid reads as sparse. On sites with a short company history, replace milestones with product releases or feature launches to keep the timeline dense.

Used by

  • Linear, Uses metric counters and a timeline of product milestones on its about page to ground the brand in measurable traction.
  • Stripe, Features animated growth figures alongside a company history timeline, combining trust signals and narrative in one scroll.
  • Vercel, Pairs key deployment statistics with chronological product milestones to show scale and momentum on the same page.
  • Notion, Combines user count milestones with a company history section, using animated counters as the lead-in trust signal.

FAQ

Why use setInterval instead of Framer Motion's animation for the counter?

setInterval gives direct control over the numeric display state, which makes it easy to render the exact integer at each frame, append a suffix, and stop precisely at the target value. Framer Motion's animate API is better suited to DOM transforms and opacity; driving a React state counter manually via interval keeps the logic straightforward.

How do I make all counters finish at the same time?

The current step formula Math.max(1, Math.floor(value / 60)) scales the increment to the target value, so a counter going to 10000 advances ~166 per tick while one going to 50 advances 1 per tick, both reach their target in roughly 60 ticks at 20ms each, meaning about 1.2 seconds total.

Can I add an icon or illustration to each milestone?

Yes. The connector dot div (absolute, left-1/2) can be swapped for a larger circle containing an icon from lucide-react. Keep it small enough (32-40px) that it doesn't push the text columns out of alignment, the w-3 h-3 dot is already absolutely positioned off the flow.

Does the counter re-animate when the user scrolls back up?

No. useInView is called with once:true, so it fires exactly once when the element first enters the viewport and never again. Remove once:true if you want it to replay on every scroll-into-view.

"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

Reviews

React Animated Counter + Timeline Section, Code & Tutorial