feat: 创建数字计数动画组件

- 创建 AnimatedNumber 组件支持平滑计数动画
- 使用 easeOutQuart 缓动函数
- 支持前缀和后缀
- 支持延迟和持续时间配置
- 创建 StatCard 统计卡片组件
- 支持滚动进入视口时触发动画
This commit is contained in:
张翔
2026-02-22 15:21:24 +08:00
parent c544b81cff
commit f27897aa1c
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { motion, useInView } from 'framer-motion';
interface AnimatedNumberProps {
value: number;
duration?: number;
delay?: number;
suffix?: string;
prefix?: string;
className?: string;
}
export function AnimatedNumber({
value,
duration = 2000,
delay = 0,
suffix = '',
prefix = '',
className = '',
}: AnimatedNumberProps) {
const [count, setCount] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const hasAnimated = useRef(false);
useEffect(() => {
if (!isInView || hasAnimated.current) return;
hasAnimated.current = true;
const startTime = Date.now() + delay;
const endTime = startTime + duration;
const animate = () => {
const now = Date.now();
if (now < startTime) {
requestAnimationFrame(animate);
return;
}
if (now >= endTime) {
setCount(value);
return;
}
const progress = (now - startTime) / duration;
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
const currentValue = Math.floor(easeOutQuart * value);
setCount(currentValue);
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [isInView, value, duration, delay]);
return (
<motion.span
ref={ref}
initial={{ opacity: 0, scale: 0.5 }}
animate={isInView ? { opacity: 1, scale: 1 } : {}}
transition={{ duration: 0.5, delay: delay / 1000 }}
className={className}
>
{prefix}{count}{suffix}
</motion.span>
);
}
interface StatCardProps {
value: number;
label: string;
suffix?: string;
prefix?: string;
index?: number;
}
export function StatCard({ value, label, suffix = '', prefix = '', index = 0 }: StatCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="text-center group cursor-default"
>
<div className="text-3xl sm:text-4xl font-bold tech-gradient-text mb-2">
<AnimatedNumber
value={value}
suffix={suffix}
prefix={prefix}
delay={index * 100}
/>
</div>
<div className="text-sm text-[var(--color-text-muted)] group-hover:text-[var(--color-text-tertiary)] transition-colors">
{label}
</div>
</motion.div>
);
}