数字递增
关键数字进入视野时从零开始增长,最后缓缓停下,让最终的数值落得有分量。
12,480已入驻团队
用 GSAP 制作一个数字递增的统计数据。把一个普通对象 { n: 0 } 在 1.6s 内补间到目标值 12480,缓动为 "power3.out",每次更新时把 Math.round(n) 用 toLocaleString() 格式化后写入数字元素。当 IntersectionObserver 检测到统计数字可见 50% 时开始补间,并添加一个“重播”按钮重新开始。数字使用 tabular-nums,避免计数时宽度抖动。把最终数值放在 aria-label 中,让屏幕阅读器只读一个数字而不是每一帧。在 prefers-reduced-motion 下直接显示最终数值。组件卸载时 revert GSAP context。说明文字为“已入驻团队”。
源代码
'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; }