Next.js Number Counter Animation: Count Up Once on Scroll
Published: September 12, 2026 · 4 min read
Animate stats in Next.js so numbers count up once when they scroll into view. Dependency-free and Motion versions, reduced motion, screen readers, captions.
When and how often should a number counter run?
When you add proof like "1,200 companies" to a Next.js landing page, it is tempting to make the number tick up from zero.
The answer is to run the count once, the moment the number scrolls into view, and finish in about one second.
What makes a number persuasive, though, is the unit and the caption that says what it counts, not the motion.
This guide covers both the counter and the layout that makes the numbers land.
A dependency-free counter built only on browser features
The same counter with Motion (formerly Framer Motion)
Reduced motion and screen reader support
Why stats look weak, and a three-stat section example
Why a number counter should run only once
The animation exists to pull the eye to the number. Once it has done that, more motion only gets in the way of reading.
Resetting to zero makes numbers forgettable
If the count restarts every time someone scrolls back, they pass it before ever seeing the final value. Once a number reaches its target, leave it there.
A count-up in the hero makes the page feel slow
Numbers moving right after load pull attention away from the headline and button, and they tend to affect Largest Contentful Paint (LCP).
Put the stats band below the hero.
Some visitors ask for less motion
People who enable reduced motion in their OS settings send prefers-reduced-motion: reduce. For them, show the final value from the start.
Number counter code with no dependencies
Start without a library. Intersection Observer is a browser API that tells you when an element enters the viewport.
It triggers the start, and requestAnimationFrame updates the number each frame.
Three details matter:
Render the final value on the server
Give screen readers only the final value
Use tabular-nums so the width does not jitter while digits change
// components/CountUp.tsx
"use client";
import { useEffect, useRef, useState } from "react";
type Props = {
value: number;
suffix?: string;
decimals?: number;
duration?: number;
};
export function CountUp({ value, suffix = "", decimals = 0, duration = 1200 }: Props) {
const ref = useRef<HTMLSpanElement>(null);
const [display, setDisplay] = useState(value);
const format = (n: number) =>
new Intl.NumberFormat("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(n);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
setDisplay(0);
let frame = 0;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3);
setDisplay(value * eased);
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
},
{ threshold: 0.6 },
);
observer.observe(el);
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [value, duration]);
return (
<span ref={ref} className="tabular-nums">
<span className="sr-only">
{format(value)}
{suffix}
</span>
<span aria-hidden="true">
{format(display)}
{suffix}
</span>
</span>
);
}Key points in the count-up code
The state (useState) starts at the final value, so the server-rendered HTML contains the real number
observer.disconnect() stops watching after the first count
The 1 - (1 - t)^3 easing slows down at the end
Intl.NumberFormat adds thousands separators and fixes the decimal places
A count-up with Framer Motion (motion)
If you already use Motion, useInView and animate make this shorter: one tells you when the element is on screen, the other animates a number. The package is now called motion and imports from "motion/react".
If you have framer-motion installed, the same names import from "framer-motion".
"use client";
import { animate, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
export function MotionCountUp({ value }: { value: number }) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduceMotion = useReducedMotion();
// Start from the final value so the server HTML shows the real number
const [display, setDisplay] = useState(value);
useEffect(() => {
if (reduceMotion) return;
if (!inView) return;
const controls = animate(0, value, {
duration: 1.2,
ease: "easeOut",
onUpdate: (v) => setDisplay(Math.round(v)),
});
return () => controls.stop();
}, [inView, reduceMotion, value]);
return (
<span ref={ref} className="tabular-nums">
{display.toLocaleString("en-US")}
</span>
);
}Build a stats section with count-up numbers
Place three CountUp components, a caption under each number, and a source line under the section. Large numbers with slightly smaller units let the number itself register first.
The markup uses a dl, the HTML list for term and value pairs, with dt (what is counted) before dd (the value).
To show the number on top visually, flex-col-reverse flips the order.
Screen readers hear "Companies using the product, 1,200" in that order.
// components/StatsSection.tsx
import { CountUp } from "./CountUp";
const stats = [
{ value: 1200, suffix: "", label: "Companies using the product (as of Sept 2026)" },
{ value: 98.2, suffix: "%", label: "Support satisfaction (customer survey)", decimals: 1 },
{ value: 40, suffix: "h", label: "Hours saved per company each month (average)" },
];
export function StatsSection() {
return (
<section className="mx-auto max-w-5xl px-6 py-24">
<dl className="grid gap-10 text-center sm:grid-cols-3">
{stats.map((s) => (
<div key={s.label} className="flex flex-col-reverse">
<dt className="mt-2 text-sm text-zinc-500">{s.label}</dt>
<dd className="text-5xl font-semibold tracking-tight">
<CountUp value={s.value} suffix={s.suffix} decimals={s.decimals} />
</dd>
</div>
))}
</dl>
<p className="mt-10 text-center text-xs text-zinc-400">
Source: internal data (September 2026)
</p>
</section>
);
}Never ship the sample numbers
1,200 and 98.2% above are examples.
Sections generated by an AI agent, or code you copied, often contain plausible-looking numbers too. Replace each one with your real data before you publish.
Why stats look weak even with a number counter
When a counter still does not convince, the cause is usually how the number is presented, not the motion.
Vague captions: "1,200" alone could be customers or users. Say what it counts
Too many stats: four or more and none stick. Keep the strongest three
No source or date: big numbers invite doubt. Add the date and method, such as "as of September 2026" or "internal survey of 120 customers"
Units as large as the number: an oversized "%" buries the figure
Many B2B landing pages do fine with the final value shown large and no counter at all. Add motion only after the numbers and captions are settled.
Copy a finished count-up stats section

Matching number size, divider lines, caption spacing, and entrance timing takes a lot of small adjustments.
DesignLayer lets you live-preview human-built motion sections in the browser. https://design-layer.com/en/components
Copy one you like and swap in your numbers and captions.
A free account copies the 10 Free Sample components with no monthly limit on a free account, plus 1 video prompt a month. Free cannot connect MCP. Full catalog copies are the 30-Day Build Pass ($19 once, no auto-renewal), Lifetime Access ($79 once, first 100 buyers), or Plus ($9/month), and live preview is unlimited. Fact-check every number after you copy.
Number counter animation: common questions
No. The component above uses two built-in browser features: Intersection Observer (is it on screen?) and requestAnimationFrame (update on each repaint). If Motion is already in your project, useInView and animate make it shorter.
Summary: a count-up number animation in Next.js
Count up once when the number enters the viewport, and finish in about a second
Give the server HTML and screen readers the final value, and skip the animation for people who ask for less motion
Keep digit widths equal and add thousands separators so nothing jitters
Show three stats at most, with captions and a source
Revealing elements on scroll is covered in https://design-layer.com/en/articles/nextjs-scroll-reveal-guide and Motion basics in https://design-layer.com/en/articles/nextjs-framer-motion-guide .
To start from a finished section, compare options in the catalog. https://design-layer.com/en/components


