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