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
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>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>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> ))}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.