wui
组件

数字滚动 Sliding Number

每个数位独立运行垂直滚轮滚动,支持弹性阻尼过渡、浮点小数与补零格式化的数字动效组件。

第三方依赖 · motion

基础用法

最简单的数字滚动用法。当绑定的数值发生改变时,受影响的数位将根据数值增减方向进行物理弹簧平滑滚动:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/sliding-number
安装依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge
复制组件源码到 components/ui/sliding-number.tsx
components/ui/sliding-number.tsx
"use client"

import * as React from "react"
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type MotionValue,
  type Transition,
  type Variants,
} from "motion/react"

import { cn } from "@/lib/utils"

type SlideDirection = -1 | 0 | 1

const defaultTransition: Transition = {
  type: "spring",
  stiffness: 260,
  damping: 28,
}

function SlidingDigitValue({
  number,
  position,
}: {
  number: number
  position: MotionValue<number>
}) {
  const y = useTransform(position, (latest) => {
    const current = ((latest % 10) + 10) % 10
    let offset = (10 + number - current) % 10

    if (offset > 5) offset -= 10

    return `${offset}em`
  })

  return (
    <motion.span
      className="absolute inset-0 text-center leading-none"
      style={{ y }}
    >
      {number}
    </motion.span>
  )
}

export interface SlidingNumberProps extends React.ComponentProps<"span"> {
  /** Number or numeric string to display. */
  value: number | string
  /** Pad single-digit integer values with a leading zero. @default false */
  padStart?: boolean
  /** Character used in place of the decimal point. @default "." */
  decimalSeparator?: string
  /** Motion transition used by every digit. */
  transition?: Transition
}

const digitVariants: Variants = {
  initial: (direction: SlideDirection) => ({
    opacity: 0,
    width: 0,
    y: direction < 0 ? "-0.3em" : "0.3em",
    filter: "blur(2px)",
  }),
  animate: { opacity: 1, width: "0.62em", y: "0em", filter: "blur(0px)" },
  exit: (direction: SlideDirection) => ({
    opacity: 0,
    width: 0,
    y: direction < 0 ? "0.3em" : "-0.3em",
    filter: "blur(2px)",
  }),
}

function SlidingDigit({
  digit,
  direction,
  transition,
  reduceMotion,
}: {
  digit: number
  direction: SlideDirection
  transition: Transition
  reduceMotion: boolean
}) {
  const position = useMotionValue(digit)
  const previousDigit = React.useRef(digit)
  const targetPosition = React.useRef(digit)
  // Read the latest direction/transition without restarting the roll on
  // unrelated re-renders.
  const latest = React.useRef({ direction, transition })

  React.useEffect(() => {
    latest.current = { direction, transition }
  })

  React.useEffect(() => {
    const previous = previousDigit.current
    if (previous === digit) return
    const { direction: currentDirection, transition: currentTransition } =
      latest.current
    let distance = digit - previous

    // Roll the long way round so every reel moves with the value's direction
    // (e.g. 9 → 0 keeps rolling up when the number increases).
    if (currentDirection > 0 && distance < 0) distance += 10
    if (currentDirection < 0 && distance > 0) distance -= 10

    targetPosition.current += distance
    previousDigit.current = digit

    if (reduceMotion) {
      position.set(targetPosition.current)
      return
    }

    const playback = animate(position, targetPosition.current, currentTransition)
    return () => playback.stop()
  }, [digit, position, reduceMotion])

  return (
    <motion.span
      data-slot="sliding-number-digit"
      className="relative inline-block h-[1em] w-[0.62em] shrink-0 overflow-hidden align-[-0.08em]"
      custom={direction}
      variants={digitVariants}
      initial={reduceMotion ? false : "initial"}
      animate="animate"
      exit="exit"
      transition={{ duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
    >
      {Array.from({ length: 10 }, (_, number) => (
        <SlidingDigitValue
          number={number}
          position={position}
          key={number}
        />
      ))}
    </motion.span>
  )
}

/** Displays each numeric digit on an independently sliding vertical reel. */
function SlidingNumber({
  value,
  padStart = false,
  decimalSeparator = ".",
  transition = defaultTransition,
  className,
  ...props
}: SlidingNumberProps) {
  const reduceMotion = useReducedMotion()
  // Ignore grouping separators and units so "12,480" still resolves a
  // direction for the roll.
  const numericValue =
    typeof value === "number"
      ? value
      : Number(value.replace(/[^\d.-]/g, ""))
  const previousValue = React.useRef(numericValue)
  const direction: SlideDirection = Number.isFinite(numericValue)
    ? numericValue > previousValue.current
      ? 1
      : numericValue < previousValue.current
        ? -1
        : 0
    : 0

  React.useEffect(() => {
    previousValue.current = numericValue
  }, [numericValue])

  let formatted = String(value)
  const [integerPart = "", fractionPart] = formatted.split(".")
  if (padStart && /^-?\d$/.test(integerPart)) {
    formatted = integerPart.startsWith("-")
      ? `-0${integerPart.slice(1)}`
      : integerPart.padStart(2, "0")
    if (fractionPart !== undefined) formatted += `.${fractionPart}`
  }
  const displayValue = formatted.replace(".", decimalSeparator)
  const characters = Array.from(displayValue)

  return (
    <span
      aria-label={displayValue}
      data-slot="sliding-number"
      className={cn("inline-flex items-baseline tabular-nums", className)}
      {...props}
    >
      <span aria-hidden="true" className="inline-flex items-baseline">
        <AnimatePresence initial={false} custom={direction}>
          {characters.map((character, index) => {
            const digit = Number(character)
            const place = characters.length - index - 1
            return Number.isInteger(digit) ? (
              <SlidingDigit
                digit={digit}
                direction={direction}
                transition={transition}
                reduceMotion={Boolean(reduceMotion)}
                key={`digit-${place}`}
              />
            ) : (
              <span
                data-slot="sliding-number-symbol"
                key={`${character}-${index}`}
              >
                {character}
              </span>
            )
          })}
        </AnimatePresence>
      </span>
    </span>
  )
}

export { SlidingNumber }

属性 Props

SlidingNumber 支持以下配置属性,并继承原生 <span> 的全部 HTML 属性:

属性类型默认值说明
valuenumber | string—当前待展示的目标数值(支持整数、浮点数以及带千分位、前缀符号的数字字符串,如 "12,480";判断滚动方向时会忽略非数字字符)。
padStartbooleanfalse是否为个位数自动在前方补零对齐(常用于时钟 09:05 或两位数倒计时)。
decimalSeparatorstring"."用于替换小数点的字符(如欧洲地区惯用的逗号 ,)。
transitionTransition{ type: "spring", stiffness: 260, damping: 28 }应用于各个数位垂直滚轮运动的 Motion 弹簧动画过渡参数。
classNamestring—应用于外层 span 容器的额外 CSS 类名(默认内置 tabular-nums)。

事件 Events

SlidingNumber 支持所有原生 <span> 事件(如 onClick, onMouseEnter 等),并将属性直接透传至外层容器:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLSpanElement>) => void—点击数字区域时触发的原生点击事件。

使用场景与设计规范

SlidingNumber 适用于需要强调量化变化趋势与实时动态的界面场景,例如实时遥测仪表盘、计费套餐切换、时钟日历以及倒计时。

  • 开启等宽数字(Tabular Nums):组件内置了 tabular-nums 类名,确保数字 1 与 8 占用完全相同的字宽,避免因数位宽度变动导致整行文字左右晃动。
  • 智能方向识别:组件内部会自动比较最新值与前次值(Previous Value),当数值上升时每一位都向上滚动(9 → 0 也继续向上绕一圈),数值下降时向下倒退;新增或移除的数位同样沿该方向滑入、滑出。
  • 稳定的动画:滚动方向与 transition 通过 ref 读取,父组件因其他原因重新渲染时不会打断正在进行的滚动。
  • 高频更新性能优化:每个数位由独立的 MotionValue 和 transform 驱动,仅改变 GPU 复合层的 transform: translateY,不会触发整行 DOM 的回流(Reflow)。

场景示例

实时数字时钟

结合 padStart 属性自动补零,呈现高精度的数字时钟看板:

--:--:--

本地时间 · 每秒更新

实时指标看板

在业务看板中,以滚动直观展示在线用户、请求量与延迟的实时波动,右侧箭头提示变化方向:

Loading…

套餐计费周期切换

在月付与年付之间切换时,价格数字平滑滚动过渡,提升定价页面的交互品质:

Loading…

发布会活动倒计时

将天、时、分、秒分别绑定至 SlidingNumber 并开启 padStart,构建发布会倒计时:

Loading…

无障碍与交互 Accessibility

  • 屏幕阅读器无障碍读数:外层容器挂载 aria-label={displayValue},内部 0~9 的滚轮数字节点均打上 aria-hidden="true" 标记。屏幕阅读器只会将最新的完整数值播报给视障用户,绝不会重复读取隐藏滚轮中的多余数字。
  • 系统减少动态偏好(Reduced Motion):当系统启用 prefers-reduced-motion 时,组件将自动跳过 Spring 物理回弹模拟,数字将以直接赋值的方式瞬时刷新,保障无障碍兼容。