feat: 创建数字计数动画组件
- 创建 AnimatedNumber 组件支持平滑计数动画 - 使用 easeOutQuart 缓动函数 - 支持前缀和后缀 - 支持延迟和持续时间配置 - 创建 StatCard 统计卡片组件 - 支持滚动进入视口时触发动画
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user