# 数字滚动 Count Up > 数字滚进屏幕时从 0 快速滚到目标值,先快后慢。 分类:动效 · 角色:点缀(贴在某个元素上,一页两三个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:3 到 4 个关键数字:用户数、成交额、增长率。 - 不适合:后台实时数据(每次刷新都滚很烦,直接显示);价格(要马上看清)。 - 适合的网站:官网、SaaS、活动页、内容站、本地小店 - 放在:内容区 - 手机上:好用:和电脑上一样。 - 性能代价:低:只在出场那一两秒换数字。 - 常搭配:[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md)、[跑马灯 Marquee](https://www.dachuiai.com/c/marquee.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react`(lucide-react 只有最后的演示示例用到;不留示例的话,删掉对应的 import 就不用装) - 文件:原样存成 `count-up.tsx`(比如 `src/components/count-up.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/count-up.tsx),比手抄准:`curl -fsSL -o src/components/count-up.tsx https://www.dachuiai.com/code/count-up.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`useInView`、`useProgress`、`CountUp` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:useInView 判断进没进屏幕,CountUp 的 run 传它的结果。滚动时长写死在文件里 useProgress(run, 1600, delay) 的 1600 毫秒。中文数字写成 to={12} suffix=" 万杯",不要写 120000(会显示成 120,000)。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **useInView** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `once` | `true` | 只触发一次 | **useProgress** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `run` | | 为 true 时开始 | | `duration` | `1600` | 动画时长(毫秒) | | `delay` | `0` | 延迟多久才开始(毫秒) | **CountUp** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `to` | | 目标数字 | | `decimals` | `0` | 小数位数 | | `run` | | 为 true 时开始 | | `delay` | `0` | 延迟多久才开始(毫秒) | | `suffix` | `''` | 数字后面的单位 | ## 你可以说 > 这几个数据做成 Count Up 数字滚动:滚进屏幕时从 0 快速滚到目标值,先快后慢。 ## 完整代码 ```tsx import {useEffect, useRef, useState} from 'react'; import {Star, ThumbsUp, TrendingUp, Users} from 'lucide-react'; /* --------------------------------------------------------------- * CountUp 数字滚动 * 数字从 0 快速滚到目标值(先快后慢),滚进屏幕时才开始。 * ------------------------------------------------------------- */ export function useInView(once = true) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setInView(true); if (once) io.disconnect(); } else if (!once) setInView(false); }, {threshold: 0.3}); if (ref.current) io.observe(ref.current); return () => io.disconnect(); }, [once]); return {ref, inView}; } /** 0 → 1 的进度,ease-out,duration 毫秒 */ export function useProgress(run: boolean, duration = 1600, delay = 0) { const [p, setP] = useState(0); useEffect(() => { if (!run) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return setP(1); let raf = 0; const t0 = performance.now() + delay; const tick = (now: number) => { const x = Math.min(1, Math.max(0, (now - t0) / duration)); setP(1 - Math.pow(1 - x, 4)); if (x < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [run, duration, delay]); return p; } export function CountUp({to, decimals = 0, run, delay = 0, suffix = ''}: {to: number; decimals?: number; run: boolean; delay?: number; suffix?: string}) { const p = useProgress(run, 1600, delay); const v = (to * p).toLocaleString('en-US', {minimumFractionDigits: decimals, maximumFractionDigits: decimals}); return ( {v}{suffix} ); } /* ---------------- 示例:数据看板 ---------------- */ const SPARK = [0.12, 0.2, 0.18, 0.3, 0.28, 0.42, 0.4, 0.55, 0.62, 0.6, 0.78, 0.9]; export default function CountUpDemo() { const {ref, inView} = useInView(); const p0 = useProgress(inView), p1 = useProgress(inView, 1600, 150), p2 = useProgress(inView, 1600, 300); const pts = SPARK.map((v, i) => [(i / (SPARK.length - 1)) * 280, 100 - v * 90]); const d = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' '); const tile = 'rounded-3xl border border-white/10 bg-white/[.04] p-6'; const label = (Icon: typeof Users, t: string, c: string) => (
{t}
); return (
{label(Users, '累计用户', 'bg-blue-500/15 text-blue-300')}
12.5% 本月
{label(ThumbsUp, '好评率', 'bg-emerald-400/15 text-emerald-300')}
%
{label(Star, '综合评分', 'bg-amber-400/15 text-amber-300')}
{Array.from({length: 5}, (_, i) => )}
{Array.from({length: 5}, (_, i) => )}
); } ```