Count Up
A key number climbs from zero to its value as it scrolls into view, then eases to a stop so the final figure lands with weight.
Create a count-up statistic with GSAP. Tween a plain object { n: 0 } to the target value 12480 over 1.6s with ease "power3.out", and on each update write Math.round(n) formatted with toLocaleString() into the number element. Start the tween when an IntersectionObserver sees the stat at 50% visibility, and add a Replay button that restarts it. Use tabular-nums on the number so the width does not jitter while it counts. Put the final value in aria-label so screen readers hear one number, not every frame. Under prefers-reduced-motion, show the final value immediately. Revert the GSAP context on unmount. The caption reads "Teams onboard".
Source
'use client'
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
import s from './demo.module.css'
export default function CountUp({ labels }: { labels: Record<string, string> }) {
const num = useRef<HTMLSpanElement>(null)
const tween = useRef<gsap.core.Tween | null>(null)
const target = Number(labels.value)
useEffect(() => {
const el = num.current
if (!el) return
const show = (n: number) => (el.textContent = Math.round(n).toLocaleString())
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return void show(target)
const state = { n: 0 }
show(0)
const ctx = gsap.context(() => {
tween.current = gsap.to(state, {
n: target,
duration: 1.6,
ease: 'power3.out',
paused: true,
onUpdate: () => show(state.n),
})
})
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return
tween.current?.play()
io.disconnect()
},
{ threshold: 0.5 },
)
io.observe(el)
return () => {
io.disconnect()
ctx.revert()
}
}, [target])
return (
<div className={s.wrap}>
<span ref={num} className={s.num} aria-label={target.toLocaleString()}>
{target.toLocaleString()}
</span>
<span className={s.caption}>{labels.caption}</span>
<button type="button" className={s.replay} onClick={() => tween.current?.restart()}>
{labels.replay}
</button>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 6px;
}
.num {
font: 700 clamp(2.4rem, 8vw, 3.2rem) / 1 var(--font-display);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
color: var(--stage-ink);
}
.caption {
margin-bottom: 10px;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--stage-muted);
}
.replay {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.replay:hover { border-color: var(--stage-accent); }
.replay:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }