Retour au catalogue

About Numbers

Section a propos centree sur les chiffres cles avec grands nombres en accent.

aboutsimple Both Responsive a11y
boldcorporateuniversalagencysaassplit
Theme

How to build an animated stats/numbers section in React

An animated stats section in React uses Framer Motion's whileInView to trigger a staggered fade-and-slide reveal for each stat card as the section scrolls into the viewport. Each card animates independently with a 100ms delay offset so they cascade rather than appear at once.

  • Stack: React + Framer Motion + Tailwind v4, ~88 lines, zero extra dependencies.
  • Animation: whileInView with viewport once:true, staggered 100ms delay per card, custom spring easing [0.16, 1, 0.3, 1].
  • Layout: CSS Grid split, text block on the left, 2×2 stat card grid on the right at lg breakpoint.
  • Fully themed via CSS custom properties; no hardcoded colors anywhere.
  • Accessible: semantic section/h2, stat values rendered as plain text (screen reader friendly), no ARIA hacks needed.

About Numbers is a React section that pairs a short narrative block with a 2×2 grid of large, accent-colored stat cards. Each card animates into view on scroll, staggered so the grid feels alive rather than a static table. It covers the most common pattern on SaaS and agency sites: numbers that build credibility in a glance.

Anatomy

The outer section uses CSS custom properties for padding and max-width so it inherits the project's spacing tokens. Inside, a single CSS Grid splits into two columns at the lg breakpoint: a left column with the h2 heading and a short description paragraph, and a right column that itself is a 2-column grid of stat cards. Each stat card is a rounded container with a background-alt fill, a single border, the numeric value in large accent type, and the label in small uppercase tracking.

How it works

The text block wraps in a single motion.div with initial opacity 0 and y:16, triggered by whileInView. The stat cards each get the same treatment but with a per-index delay of i * 0.1 seconds, creating a natural left-to-right, top-to-bottom cascade. All transitions share a custom cubic-bezier ease [0.16, 1, 0.3, 1], a fast-out curve that feels snappy without being abrupt. The viewport option once:true prevents re-animation on scroll-up, keeping the experience clean.

How to build it in React

  1. Set up the split grid layout

    Wrap everything in a section that reads padding and max-width from CSS tokens. Inside, use a CSS Grid with grid-cols-1 on mobile and lg:grid-cols-2 at desktop, with items-center so the two halves align vertically. This is the structural shell.

    <section style={{ padding: "var(--section-padding-y)", background: "var(--color-background)" }}>
      <div className="mx-auto" style={{ maxWidth: "var(--container-max-width)" }}>
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
          {/* text block */}
          {/* stats grid */}
        </div>
      </div>
    </section>
  2. Animate the text block on scroll

    Wrap the heading and paragraph in a Framer Motion motion.div. Set initial to opacity:0 and y:16, then whileInView to opacity:1 and y:0. Pass viewport={{ once: true }} so the animation fires once only. Use the custom EASE constant for a snappy feel.

    const EASE = [0.16, 1, 0.3, 1] as const;
    
    <motion.div
      initial={{ opacity: 0, y: 16 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true }}
      transition={{ duration: 0.5, ease: EASE }}
    >
      <h2>{title}</h2>
      <p>{description}</p>
    </motion.div>
  3. Build the staggered stat cards

    Map over the stats array, wrapping each card in its own motion.div. Pass delay: i * 0.1 in the transition so cards enter one after another. Each card reads border-radius, background, and border color from CSS tokens, no hardcoded values. The value renders in a span with the accent color, and the label in a smaller muted span.

    {stats.map((stat, i) => (
      <motion.div
        key={stat.label}
        initial={{ opacity: 0, y: 20 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true }}
        transition={{ duration: 0.45, delay: i * 0.1, ease: EASE }}
        style={{
          background: "var(--color-background-alt)",
          border: "1px solid var(--color-border)",
          borderRadius: "var(--radius-lg)",
        }}
      >
        <span style={{ color: "var(--color-accent)" }}>{stat.value}</span>
        <span style={{ color: "var(--color-foreground-muted)" }}>{stat.label}</span>
      </motion.div>
    ))}
  4. Supply the stats data

    Each stat is an object with a value string and a label string. Values are pre-formatted strings like "12k+" or "99%", no runtime number crunching needed. Pass them via the stats prop or replace the default mock with your real data at the call site.

    const stats = [
      { value: "12k+", label: "Clients" },
      { value: "99%", label: "Satisfaction" },
      { value: "8 ans", label: "Expérience" },
      { value: "40+", label: "Pays" },
    ];
    
    <AboutNumbers title="Nos chiffres" description="..." stats={stats} />

When to use it

Use this section mid-page on a company landing page, SaaS marketing site, or agency portfolio where you need to establish trust with numbers before the CTA. It works best with 4 to 6 stats of similar visual weight. Skip it when your numbers are not yet strong enough to impress (early-stage products) or when the page is already heavy on data tables and charts, adding another number block creates noise.

Used by

  • Stripe, Uses large bold numbers in split layouts across its home and product pages to highlight volume and reliability metrics.
  • Notion, Features a numbers-forward about section on its company page with staggered card reveals.
  • Intercom, Employs grid-based stat blocks mid-page on marketing pages to build credibility before the pricing section.
  • Webflow, Pairs a short brand narrative with a card grid of platform metrics on its home and about pages.

FAQ

How do I add a number counting animation (count-up effect)?

The current implementation displays pre-formatted strings, which keeps the component simple and avoids re-renders. To add a count-up, replace the value span with a component that uses Framer Motion's useMotionValue and animate it from 0 to the target number inside a useEffect triggered by an IntersectionObserver or the whileInView callback.

Can I have more than 4 stat cards?

The grid is grid-cols-2 with no fixed row count, so it adapts to any even number of items. Odd numbers will leave a gap in the last row. For 6 cards, the layout works fine. Past 8, consider switching to a horizontal scrolling row or a 3-column grid at wider breakpoints.

Why use whileInView instead of useEffect + IntersectionObserver?

whileInView is a thin wrapper around IntersectionObserver built into Framer Motion. Since the component already depends on Framer Motion for the animation itself, using whileInView avoids a separate observer setup and keeps the code shorter. There is no performance difference.

How do I change the accent color for the stat values?

The values use `color: var(--color-accent)` which inherits from the active theme preset. Switch the data-theme attribute on a parent element to change the entire palette, or override the variable locally with an inline style on the section if you need a one-off color.

"use client";

import { motion } from "framer-motion";

interface Stat {
  value: string;
  label: string;
}

interface AboutNumbersProps {
  title?: string;
  description?: string;
  stats?: Stat[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

export default function AboutNumbers({
  title = "Nos chiffres parlent d'eux-memes",
  description = "Depuis notre creation, nous accompagnons des entreprises ambitieuses.",
  stats = [],
}: AboutNumbersProps) {

Code complet réservé à Pro

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

Passer en Pro, 9,99€/mois

Reviews

React Stats Section with Animated Numbers, Framer Motion