Skip to content

Commit 2f2865e

Browse files
committed
feat: 添加NumberTicker组件
1 parent 64faefe commit 2f2865e

File tree

1 file changed

+105
-0
lines changed

1 file changed

+105
-0
lines changed

src/components/NumberTicker.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import clsx from 'clsx';
2+
import type { AnimationPlaybackControls } from 'motion/react';
3+
import { animate, useInView, useMotionValue, useSpring } from 'motion/react';
4+
import type { ComponentPropsWithoutRef } from 'react';
5+
import { useEffect, useRef } from 'react';
6+
7+
interface NumberTickerProps extends ComponentPropsWithoutRef<'span'> {
8+
/** - 小数位数 */
9+
decimalPlaces?: number;
10+
/** - 延时时间 单位:秒 */
11+
delay?: number;
12+
/** - 动画方向 */
13+
direction?: 'down' | 'up';
14+
/** - 动画时长 单位:秒 如果不指定 则使用spring动画 更为自然地过渡 */
15+
duration?: number;
16+
/** - 前缀 */
17+
prefix?: string;
18+
/** - 起始值 */
19+
startValue?: number;
20+
/** - 后缀 */
21+
suffix?: string;
22+
/** - 目标值 */
23+
value: number;
24+
}
25+
26+
const NumberTicker = ({
27+
className,
28+
decimalPlaces = 0,
29+
delay = 0,
30+
direction = 'up',
31+
duration,
32+
prefix = '',
33+
startValue = 0,
34+
suffix = '',
35+
value,
36+
...props
37+
}: NumberTickerProps) => {
38+
const ref = useRef<HTMLSpanElement>(null);
39+
40+
const endValue = direction === 'down' ? 0 : value;
41+
42+
const motionValue = useMotionValue(direction === 'down' ? value : 0);
43+
44+
const isInView = useInView(ref, { margin: '0px', once: true });
45+
46+
const springValue = useSpring(motionValue, {
47+
damping: 60,
48+
stiffness: 100
49+
});
50+
51+
function updateTextContent(latest: number) {
52+
if (!ref.current) return;
53+
54+
const formattedNumber = Intl.NumberFormat('en-US', {
55+
maximumFractionDigits: decimalPlaces,
56+
minimumFractionDigits: decimalPlaces
57+
}).format(Number(latest.toFixed(decimalPlaces)));
58+
ref.current.textContent = `${prefix} ${formattedNumber} ${suffix}`;
59+
}
60+
61+
useEffect(() => {
62+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
63+
isInView &&
64+
setTimeout(() => {
65+
motionValue.set(endValue);
66+
}, delay * 1000);
67+
}, [motionValue, isInView, delay, endValue]);
68+
69+
useEffect(() => {
70+
let animation: AnimationPlaybackControls;
71+
if (duration) {
72+
animation = animate(
73+
motionValue.get(), // 起始值
74+
endValue, // 目标值
75+
{
76+
duration, // 动画时长(秒)
77+
onUpdate: latest => {
78+
// 更新 motionValue(可选:如果后续还有依赖该值的逻辑)
79+
motionValue.set(latest);
80+
81+
updateTextContent(latest);
82+
}
83+
}
84+
);
85+
} else {
86+
springValue.on('change', latest => {
87+
updateTextContent(latest);
88+
});
89+
}
90+
return () => animation?.cancel();
91+
// eslint-disable-next-line react-hooks/exhaustive-deps
92+
}, [motionValue, springValue, endValue, duration]);
93+
94+
return (
95+
<span
96+
className={clsx('inline-block tabular-nums tracking-wider text-white dark:text-dark', className)}
97+
ref={ref}
98+
{...props}
99+
>
100+
{prefix} {startValue} {suffix}
101+
</span>
102+
);
103+
};
104+
105+
export default NumberTicker;

0 commit comments

Comments
 (0)