组件
数字滚动 Number Ticker
进入视口时从起始值计数到目标值,数值变化时从当前值平滑过渡,支持本地化格式、小数位与前后缀。
基础用法
传入目标值 value,数字会在滚动进入视口时从 from(默认 0)计数到目标值。默认使用无回弹的弹簧曲线,结尾减速自然且不会超出目标值:
12,846
家企业正在使用我们管理研发流程
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/number-ticker安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/number-ticker.tsx"use client"
import * as React from "react"
import {
animate,
useInView,
useMotionValue,
useReducedMotion,
type Transition,
type UseInViewOptions,
} from "motion/react"
import { cn } from "@/lib/utils"
export interface NumberTickerProps
extends Omit<React.ComponentProps<"span">, "children" | "prefix"> {
/** Target value the number animates to. */
value: number
/** Value the first animation starts from. @default 0 */
from?: number
/** Seconds to wait before the first animation starts. @default 0 */
delay?: number
/** Fixed number of decimal places. @default 0 */
decimalPlaces?: number
/** BCP 47 locale used by `Intl.NumberFormat`. @default "zh-CN" */
locale?: string
/** Extra `Intl.NumberFormat` options such as currency, percent or compact notation. */
formatOptions?: Intl.NumberFormatOptions
/** Text rendered before the number. */
prefix?: React.ReactNode
/** Text rendered after the number. */
suffix?: React.ReactNode
/** Wait until the number scrolls into view before counting. @default true */
startOnView?: boolean
/** Intersection options used when `startOnView` is on. */
viewOptions?: UseInViewOptions
/** Motion transition for every count. Defaults to a spring with no overshoot. */
transition?: Transition
/** Called when a count finishes, with the final value. */
onComplete?: (value: number) => void
}
const defaultTransition: Transition = {
type: "spring",
duration: 1.6,
bounce: 0,
}
/**
* Counts up or down to `value` once it scrolls into view, then animates from
* the current number whenever `value` changes.
*/
function NumberTicker({
value,
from = 0,
delay = 0,
decimalPlaces = 0,
locale = "zh-CN",
formatOptions,
prefix,
suffix,
startOnView = true,
viewOptions,
transition = defaultTransition,
onComplete,
className,
...props
}: NumberTickerProps) {
const ref = React.useRef<HTMLSpanElement>(null)
const numberRef = React.useRef<HTMLSpanElement>(null)
const reduceMotion = useReducedMotion()
const inView = useInView(ref, { once: true, ...viewOptions })
const motionValue = useMotionValue(from)
const hasStarted = React.useRef(false)
const options = React.useRef({ transition, delay, onComplete })
React.useEffect(() => {
options.current = { transition, delay, onComplete }
})
const formatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
...formatOptions,
}),
[decimalPlaces, formatOptions, locale]
)
React.useEffect(() => {
const node = numberRef.current
if (!node) return
node.textContent = formatter.format(motionValue.get())
return motionValue.on("change", (latest) => {
node.textContent = formatter.format(latest)
})
}, [formatter, motionValue])
React.useEffect(() => {
if (startOnView && !inView) return
if (reduceMotion) {
motionValue.jump(value)
return
}
const { transition, delay, onComplete } = options.current
const firstRun = !hasStarted.current
hasStarted.current = true
const controls = animate(motionValue, value, {
...transition,
delay: firstRun ? delay : 0,
onComplete: () => onComplete?.(value),
})
return () => controls.stop()
}, [inView, motionValue, reduceMotion, startOnView, value])
return (
<span
ref={ref}
data-slot="number-ticker"
className={cn("inline-flex items-baseline tabular-nums", className)}
{...props}
>
<span className="sr-only">
{prefix}
{formatter.format(value)}
{suffix}
</span>
<span aria-hidden="true" className="inline-flex items-baseline">
{prefix}
<span ref={numberRef} data-slot="number-ticker-value">
{formatter.format(from)}
</span>
{suffix}
</span>
</span>
)
}
export { NumberTicker }
属性 Props
NumberTicker 支持以下配置属性,并继承原生 <span> 的其余 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | number | — | 目标数值。之后每次变化都会从当前显示值过渡到新值。 |
| from | number | 0 | 首次计数的起始值。设为大于 `value` 的数即为倒数。 |
| delay | number | 0 | 首次计数前的延迟(秒),可用于多个指标错峰启动。 |
| decimalPlaces | number | 0 | 固定保留的小数位数,计数过程中位数保持不变,避免宽度抖动。 |
| locale | string | "zh-CN" | 传给 `Intl.NumberFormat` 的语言区域,决定千分位与小数点符号。 |
| formatOptions | Intl.NumberFormatOptions | — | 额外的格式化选项,例如货币 `{ style: "currency", currency: "CNY" }` 或紧凑记数 `{ notation: "compact" }`。 |
| prefix | React.ReactNode | — | 数字前的内容,例如货币符号或趋势图标。 |
| suffix | React.ReactNode | — | 数字后的内容,例如单位或百分号。 |
| startOnView | boolean | true | 是否等到滚动进入视口再开始计数。关闭后挂载即开始。 |
| viewOptions | UseInViewOptions | — | 视口检测参数,例如 `{ amount: 0.6 }` 或 `{ margin: "-80px" }`。 |
| transition | Transition | { type: "spring", duration: 1.6, bounce: 0 } | 计数使用的 Motion 过渡参数。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onComplete | (value: number) => void | — | 每次计数结束时触发,参数为最终数值。 |
使用场景与设计规范
NumberTicker 适合营销页的规模数据、仪表盘 KPI 与余额等需要强调“变化”的数字。若需要逐位滚轮效果,请使用 滑动数字 Sliding Number。
- 只动一次:营销页的规模数据通常只需在首次进入视口时计数,不要反复播放。
- 固定小数位与等宽数字:组件默认启用
tabular-nums,配合decimalPlaces可以保证计数过程中宽度稳定。 - 不要超调:默认过渡没有回弹。自定义弹簧时请保持
bounce: 0,避免百分比等指标短暂超过 100%。
场景示例
KPI 指标行
多个指标通过 delay 错峰启动,货币使用 formatOptions,百分比使用 decimalPlaces 与 suffix:
Loading…
实时数值更新
点击刷新切换数据快照,数字从当前值过渡到新值,而不是从 0 重新开始:
Loading…
无障碍与交互 Accessibility
- 只读最终值:计数过程中的数字设置了
aria-hidden,组件另外输出包含前后缀与最终格式化结果的sr-only文本,读屏软件只会读到目标值。 - 减少动态效果:开启
prefers-reduced-motion时直接显示目标值,不再计数。 - SSR 友好:服务端与首屏渲染都输出
from的格式化结果,计数在挂载后通过直接写入文本节点完成,不会触发整棵组件树的重复渲染。