How to build an animated numbers section in React with Framer Motion
An animated numbers section in React triggers a counting animation when the stat enters the viewport, using Framer Motion's useInView for detection and a setInterval loop that increments toward the final value over ~1500ms. Each metric card fades and slides up with a staggered delay so the grid reads left-to-right.
- Stack: React 19 + Framer Motion 11 + Lucide React, ~185 lines, zero extra dependencies.
- Counter logic: setInterval over 40 steps in 1500ms; useInView with once:true prevents re-triggering on scroll back.
- Layout: two-column CSS grid (text left, 2×2 metric cards right), collapses on mobile via responsive design.
- Accessible: fontVariantNumeric: tabular-nums prevents layout shift as digits change; text content remains readable without JS.
- Theming via CSS custom properties exclusively, compatible with all 7 presets, no hardcoded colors.
About Numbers Animated is a split-layout React section that pairs a title and description on the left with a grid of key metrics on the right. Each number counts up from zero the first time it scrolls into view, giving the page a moment of momentum. The pattern suits any product or company page that needs to make scale tangible without relying on a wall of prose.
Anatomy
The outer section uses CSS custom properties for padding and background-alt. Inside, a two-column CSS grid separates the editorial content from the metrics. The left column holds a labeled eyebrow (TrendingUp icon + subtitle), an h2 headline, and a paragraph. The right column is its own 2×2 grid of cards; each card shows the animated number in accent color at the top and the metric label below, wrapped in a bordered, rounded card with card-background fill.
How it works
The AnimatedCounter subcomponent attaches a ref to its span and calls Framer Motion's useInView with once:true. When isInView flips to true, a setInterval fires every 37.5ms (1500ms / 40 steps) and adds value/40 to the running count. Math.floor keeps intermediate states as integers, and clearInterval stops execution exactly at the target. The parent section animates each metric card via whileInView with a staggered delay (i * 0.08s) and an expo-out easing curve [0.16, 1, 0.3, 1], so the cards land with a light elastic feel rather than a flat fade.
How to build it in React
Create the AnimatedCounter subcomponent
Extract the counting logic into a small subcomponent. Attach a ref to the displayed span, then use Framer Motion's useInView to detect when it enters the viewport. Start the interval only when isInView becomes true, and clean it up on unmount.
const ref = useRef<HTMLSpanElement>(null); const isInView = useInView(ref, { once: true }); useEffect(() => { if (!isInView) return; const steps = 40; const increment = value / steps; let current = 0; const timer = setInterval(() => { current += increment; if (current >= value) { setCount(value); clearInterval(timer); } else setCount(Math.floor(current)); }, 1500 / steps); return () => clearInterval(timer); }, [isInView, value]);Build the two-column split layout
Wrap the section in a container div using CSS grid with two equal columns and a 4rem gap. Place your editorial text on the left and a nested 2×2 grid for the metric cards on the right. Use CSS custom properties for spacing so the section adapts to every theme preset.
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4rem", alignItems: "center" }}> {/* Left: text */} <motion.div initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}> {/* title + description */} </motion.div> {/* Right: 2x2 metrics */} <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}> {metrics.map((m, i) => ( <motion.div key={i} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08, ease: [0.16, 1, 0.3, 1] }} viewport={{ once: true }}> <AnimatedCounter value={m.value} suffix={m.suffix} /> <p>{m.label}</p> </motion.div> ))} </div> </div>Style numbers with tabular-nums to prevent layout shift
As digits change from 0 to the target, variable-width characters cause cards to jitter. Set fontVariantNumeric: 'tabular-nums' on the number element so every digit occupies the same horizontal space throughout the animation.
<p style={{ fontSize: "clamp(2rem, 4vw, 3rem)", fontWeight: 700, color: "var(--color-accent)", fontVariantNumeric: "tabular-nums", }}> <AnimatedCounter value={metric.value} suffix={metric.suffix} /> </p>Pass metrics as props for easy customization
Define a Metric interface with value (number), suffix (string, e.g. '+' or '%'), and label. Accept a metrics array prop so consumers can drop in their own stats without touching the component internals. The default props in the implementation use four illustrative values as a fallback.
interface Metric { value: number; suffix: string; label: string; } // Usage <AboutNumbersAnimated sectionTitle="By the numbers" description="We've helped 500+ teams ship faster." metrics={[ { value: 500, suffix: "+", label: "Customers" }, { value: 98, suffix: "%", label: "Satisfaction" }, { value: 40, suffix: "+", label: "Countries" }, { value: 12, suffix: "M", label: "Events/month" }, ]} />
When to use it
Reach for this section when you need to translate company scale into a tangible format on an About or Marketing page. It works well mid-page after a team intro or a mission statement, letting metrics reinforce the narrative. Skip it when the numbers are not yet impressive or when the page is already data-heavy; a sparse, weak stat block reads worse than no block at all. On mobile the two-column grid stacks, so verify the stacked layout still reads well before shipping.
Used by
- Stripe, Uses animated stat counters on its About page to show payment volume and developer reach.
- Linear, Displays company milestones as scroll-triggered numbers on its About page.
- Notion, Highlights user count and workspace statistics with count-up animations to reinforce traction.
- Vercel, Animated deployment and developer numbers appear on scroll in its marketing and about sections.
FAQ
Why use setInterval instead of Framer Motion's animate()?
setInterval gives direct control over the integer rounding needed for clean whole-number display. Framer Motion's animate() interpolates floats, which requires extra clamping and a custom output transformer. For a counter that must display integers, setInterval with Math.floor is simpler and has no extra overhead.
How do I trigger the counter every time it scrolls into view, not just once?
Change once: true to once: false in the useInView call, then also reset setCount(0) at the start of the useEffect. This replays the animation each time the element enters the viewport. For most marketing pages the once:true behavior is preferred, repeating counts can feel distracting.
Does the layout work on mobile?
The component uses a two-column CSS grid that does not automatically collapse on small screens. Add a media query or a responsive grid (gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))') to stack the text and metric columns vertically on mobile. The 2×2 metric grid inside can stay as-is or collapse to a single column.
How do I add a decimal value like 4.8 stars?
Change the count state type to number (it already is), remove Math.floor from the increment line, and use count.toFixed(1) in the render. Adjust the increment to value / steps and the clearInterval condition to current >= value as before.