wui
组件

数字滚动 Number Ticker

进入视口时从起始值计数到目标值,数值变化时从当前值平滑过渡,支持本地化格式、小数位与前后缀。

第三方依赖 · motion

基础用法

传入目标值 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
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 属性:

属性类型默认值说明
valuenumber—目标数值。之后每次变化都会从当前显示值过渡到新值。
fromnumber0首次计数的起始值。设为大于 `value` 的数即为倒数。
delaynumber0首次计数前的延迟(秒),可用于多个指标错峰启动。
decimalPlacesnumber0固定保留的小数位数,计数过程中位数保持不变,避免宽度抖动。
localestring"zh-CN"传给 `Intl.NumberFormat` 的语言区域,决定千分位与小数点符号。
formatOptionsIntl.NumberFormatOptions—额外的格式化选项,例如货币 `{ style: "currency", currency: "CNY" }` 或紧凑记数 `{ notation: "compact" }`。
prefixReact.ReactNode—数字前的内容,例如货币符号或趋势图标。
suffixReact.ReactNode—数字后的内容,例如单位或百分号。
startOnViewbooleantrue是否等到滚动进入视口再开始计数。关闭后挂载即开始。
viewOptionsUseInViewOptions—视口检测参数,例如 `{ amount: 0.6 }` 或 `{ margin: "-80px" }`。
transitionTransition{ 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 的格式化结果,计数在挂载后通过直接写入文本节点完成,不会触发整棵组件树的重复渲染。