组件
数字滚动 Sliding Number
每个数位独立运行垂直滚轮滚动,支持弹性阻尼过渡、浮点小数与补零格式化的数字动效组件。
基础用法
最简单的数字滚动用法。当绑定的数值发生改变时,受影响的数位将根据数值增减方向进行物理弹簧平滑滚动:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/sliding-number安装依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | number | string | — | 当前待展示的目标数值(支持整数、浮点数以及带千分位、前缀符号的数字字符串,如 "12,480";判断滚动方向时会忽略非数字字符)。 |
| padStart | boolean | false | 是否为个位数自动在前方补零对齐(常用于时钟 09:05 或两位数倒计时)。 |
| decimalSeparator | string | "." | 用于替换小数点的字符(如欧洲地区惯用的逗号 ,)。 |
| transition | Transition | { type: "spring", stiffness: 260, damping: 28 } | 应用于各个数位垂直滚轮运动的 Motion 弹簧动画过渡参数。 |
| className | string | — | 应用于外层 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 物理回弹模拟,数字将以直接赋值的方式瞬时刷新,保障无障碍兼容。