chore: 更新构建ID和相关文件引用
This commit is contained in:
@@ -681,6 +681,144 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes inkDrop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
filter: blur(2px);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brushStroke {
|
||||
0% {
|
||||
stroke-dashoffset: 1000;
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sealPress {
|
||||
0% {
|
||||
transform: scale(1.8) rotate(-20deg);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.9) rotate(2deg);
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.05) rotate(-1deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes inkBleed {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(28, 28, 28, 0.1);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 20px 10px rgba(28, 28, 28, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandWidth {
|
||||
0% {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -693,6 +831,136 @@
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-ripple {
|
||||
animation: ripple 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-ink-drop {
|
||||
animation: inkDrop 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-seal-press {
|
||||
animation: sealPress 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-ink-bleed {
|
||||
animation: inkBleed 1s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(196, 30, 58, 0) 0%,
|
||||
rgba(196, 30, 58, 0.1) 50%,
|
||||
rgba(196, 30, 58, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.animate-breathe {
|
||||
animation: breathe 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-expand-width {
|
||||
animation: expandWidth 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(28, 28, 28, 0.1);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
transition: box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(196, 30, 58, 0.3);
|
||||
}
|
||||
|
||||
.hover-ink {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-ink::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: rgba(28, 28, 28, 0.05);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1), height 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.hover-ink:hover::before {
|
||||
width: 300%;
|
||||
height: 300%;
|
||||
}
|
||||
|
||||
.ink-border {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ink-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #1C1C1C, #C41E3A);
|
||||
transition: width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ink-border:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.seal-stamp {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.seal-stamp::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border: 2px solid rgba(196, 30, 58, 0.3);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.seal-stamp:hover::before {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.primary-gradient {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
|
||||
import { FadeUp, StaggerContainer, StaggerItem, CountUp, FloatingElement, SplitText, GradientText, MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
|
||||
import { COMPANY_INFO, STATS } from '@/lib/constants';
|
||||
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
|
||||
|
||||
@@ -62,24 +63,97 @@ export function HeroSection() {
|
||||
<section
|
||||
id="home"
|
||||
ref={sectionRef}
|
||||
className="relative min-h-screen flex items-center pt-16 overflow-hidden bg-linear-to-b from-[#F5F7FA] to-white"
|
||||
className="relative min-h-screen flex items-center pt-16 overflow-hidden bg-gradient-to-b from-[#FAFAFA] to-white"
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-200 h-150 bg-[radial-gradient(ellipse_at_center,rgba(79,70,229,0.06)_0%,transparent_60%)]" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-[radial-gradient(ellipse_at_center,rgba(28,28,28,0.03)_0%,transparent_60%)]"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-150 h-100 bg-[radial-gradient(ellipse_at_center,rgba(196,30,58,0.04)_0%,transparent_50%)]" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1.2, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-[radial-gradient(ellipse_at_center,rgba(196,30,58,0.04)_0%,transparent_50%)]"
|
||||
/>
|
||||
|
||||
<FloatingElement amplitude={20} duration={6} className="absolute top-[15%] left-[5%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.8 }}
|
||||
className="w-4 h-4 bg-[#C41E3A]/30 rounded-full blur-[1px]"
|
||||
/>
|
||||
</FloatingElement>
|
||||
|
||||
<FloatingElement amplitude={25} duration={7} delay={0.3} className="absolute top-[25%] right-[8%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.7, duration: 0.8 }}
|
||||
className="w-6 h-6 bg-[#1C1C1C]/20 rounded-full blur-[1px]"
|
||||
/>
|
||||
</FloatingElement>
|
||||
|
||||
<FloatingElement amplitude={18} duration={5} delay={0.6} className="absolute bottom-[30%] left-[10%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.9, duration: 0.8 }}
|
||||
className="w-3 h-3 bg-[#C41E3A]/25 rounded-full"
|
||||
/>
|
||||
</FloatingElement>
|
||||
|
||||
<FloatingElement amplitude={22} duration={6.5} delay={0.2} className="absolute bottom-[20%] right-[12%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 1.1, duration: 0.8 }}
|
||||
className="w-5 h-5 bg-[#1C1C1C]/15 rounded-full blur-[1px]"
|
||||
/>
|
||||
</FloatingElement>
|
||||
|
||||
<FloatingElement amplitude={15} duration={5.5} delay={0.8} className="absolute top-[40%] left-[15%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 1.3, duration: 0.8 }}
|
||||
className="w-2 h-2 bg-[#C41E3A]/40 rounded-full"
|
||||
/>
|
||||
</FloatingElement>
|
||||
|
||||
<FloatingElement amplitude={30} duration={8} delay={0.4} className="absolute top-[60%] right-[5%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 1.5, duration: 0.8 }}
|
||||
className="w-8 h-8 border-2 border-[#C41E3A]/20 rounded-full"
|
||||
/>
|
||||
</FloatingElement>
|
||||
|
||||
<FloatingElement amplitude={12} duration={4.5} delay={1} className="absolute top-[35%] right-[20%]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 1.7, duration: 0.8 }}
|
||||
className="w-2 h-2 bg-[#1C1C1C]/30 rounded-full"
|
||||
/>
|
||||
</FloatingElement>
|
||||
</div>
|
||||
|
||||
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0, scale: 1 } : {}}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mb-8"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[#1C1C1C]/20 bg-[#F5F5F5] text-[#1C1C1C] text-sm font-medium">
|
||||
数字印章 · 智连未来
|
||||
智连未来 · 与客户共同成长
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
@@ -89,26 +163,22 @@ export function HeroSection() {
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-5xl sm:text-6xl lg:text-7xl font-bold tracking-tight mb-6"
|
||||
>
|
||||
<span className="text-[#1A1A2E]">{COMPANY_INFO.shortName}</span>
|
||||
<SplitText text={COMPANY_INFO.shortName} className="text-[#1A1A2E]" delay={0.2} />
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.15 }}
|
||||
className="text-xl sm:text-2xl text-[#4A5568] mb-4"
|
||||
>
|
||||
企业数字化转型服务商
|
||||
</motion.p>
|
||||
<BlurReveal delay={0.3}>
|
||||
<p className="text-xl sm:text-2xl text-[#4A5568] mb-4">
|
||||
<GradientText colors={['#C41E3A', '#E04A68', '#C41E3A']} duration={4}>
|
||||
企业数字化转型服务商
|
||||
</GradientText>
|
||||
</p>
|
||||
</BlurReveal>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-lg text-[#718096] mb-10 max-w-2xl mx-auto leading-relaxed"
|
||||
>
|
||||
融合金融科技专业品质与中国传统美学,为您打造卓越的数字体验
|
||||
</motion.p>
|
||||
<BlurReveal delay={0.4}>
|
||||
<p className="text-lg text-[#718096] mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||
融合金融科技专业品质与中国传统美学,为您打造卓越的数字体验
|
||||
</p>
|
||||
</BlurReveal>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -116,22 +186,26 @@ export function HeroSection() {
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => handleScrollTo('contact')}
|
||||
className="min-w-45"
|
||||
>
|
||||
立即咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => handleScrollTo('about')}
|
||||
className="min-w-45"
|
||||
>
|
||||
了解更多
|
||||
</Button>
|
||||
<MagneticButton strength={0.4}>
|
||||
<SealButton
|
||||
size="lg"
|
||||
onClick={() => handleScrollTo('contact')}
|
||||
className="min-w-[180px]"
|
||||
>
|
||||
立即咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</SealButton>
|
||||
</MagneticButton>
|
||||
<MagneticButton strength={0.4}>
|
||||
<RippleButton
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => handleScrollTo('about')}
|
||||
className="min-w-[180px]"
|
||||
>
|
||||
了解更多
|
||||
</RippleButton>
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -141,13 +215,17 @@ export function HeroSection() {
|
||||
className="flex flex-wrap gap-4 justify-center mb-16"
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full bg-[#FAFAFA] border border-[#E5E5E5] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-md"
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={isVisible ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 0.4, delay: 0.4 + index * 0.1 }}
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full bg-[#FAFAFA] border border-[#E5E5E5] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-md cursor-default"
|
||||
>
|
||||
<feature.icon className="w-4 h-4 text-[#C41E3A]" />
|
||||
<span className="text-sm text-[#3D3D3D]">{feature.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -186,13 +264,19 @@ function StatItem({ stat, index, shouldAnimate }: {
|
||||
return (
|
||||
<motion.div
|
||||
className="group cursor-default text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={shouldAnimate ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={shouldAnimate ? { opacity: 1, y: 0, scale: 1 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1, type: 'spring', stiffness: 100 }}
|
||||
whileHover={{ scale: 1.05, y: -5 }}
|
||||
>
|
||||
<div className="text-4xl sm:text-5xl font-bold text-[#C41E3A] mb-3">
|
||||
{shouldAnimate ? (
|
||||
<AnimatedCounter value={numericValue} suffix={suffix} />
|
||||
<CounterWithEffect
|
||||
end={numericValue}
|
||||
suffix={suffix}
|
||||
effect="bounce"
|
||||
duration={2000}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[#CBD5E0]">0{suffix}</span>
|
||||
)}
|
||||
@@ -203,28 +287,3 @@ function StatItem({ stat, index, shouldAnimate }: {
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedCounter({ value, suffix }: { value: number; suffix: string }) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 2000;
|
||||
const steps = 60;
|
||||
const increment = value / steps;
|
||||
let current = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
if (current >= value) {
|
||||
setCount(value);
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
setCount(Math.floor(current));
|
||||
}
|
||||
}, duration / steps);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [value]);
|
||||
|
||||
return <>{count}{suffix}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { useRef, useState, type ReactNode, type MouseEvent } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface InkCardProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hoverScale?: number;
|
||||
hoverRotate?: number;
|
||||
inkColor?: string;
|
||||
showInkOnHover?: boolean;
|
||||
}
|
||||
|
||||
export function InkCard({
|
||||
children,
|
||||
className = '',
|
||||
hoverScale = 1.02,
|
||||
hoverRotate = 0,
|
||||
inkColor = 'rgba(196, 30, 58, 0.05)',
|
||||
showInkOnHover = true,
|
||||
...props
|
||||
}: InkCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [inkPosition, setInkPosition] = useState({ x: 50, y: 50 });
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (!cardRef.current) return;
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setInkPosition({ x, y });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
whileHover={{
|
||||
scale: hoverScale,
|
||||
rotate: hoverRotate,
|
||||
y: -4,
|
||||
}}
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-white border border-[#E5E5E5] rounded-xl transition-shadow duration-300',
|
||||
'hover:shadow-[0_12px_24px_rgba(28,28,28,0.08)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showInkOnHover && (
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isHovered ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div
|
||||
className="absolute w-[200%] h-[200%] rounded-full"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${inkColor} 0%, transparent 70%)`,
|
||||
left: `${inkPosition.x - 100}%`,
|
||||
top: `${inkPosition.y - 100}%`,
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GeometricCardProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
cornerColor?: string;
|
||||
}
|
||||
|
||||
export function GeometricCard({
|
||||
children,
|
||||
className = '',
|
||||
cornerColor = '#C41E3A',
|
||||
...props
|
||||
}: GeometricCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
whileHover={{
|
||||
y: -4,
|
||||
transition: { type: 'spring', stiffness: 300, damping: 20 },
|
||||
}}
|
||||
className={cn(
|
||||
'relative bg-white border border-[#E5E5E5] rounded-xl p-6 transition-all duration-300',
|
||||
'hover:shadow-[0_12px_24px_rgba(28,28,28,0.08)]',
|
||||
'group',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 rounded-tl-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
|
||||
<div className="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 rounded-tr-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
|
||||
<div className="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 rounded-bl-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 rounded-br-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300" style={{ borderColor: cornerColor }} />
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FlipCardProps {
|
||||
front: ReactNode;
|
||||
back: ReactNode;
|
||||
className?: string;
|
||||
frontClassName?: string;
|
||||
backClassName?: string;
|
||||
}
|
||||
|
||||
export function FlipCard({
|
||||
front,
|
||||
back,
|
||||
className = '',
|
||||
frontClassName = '',
|
||||
backClassName = '',
|
||||
}: FlipCardProps) {
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn('relative cursor-pointer perspective-1000', className)}
|
||||
onClick={() => setIsFlipped(!isFlipped)}
|
||||
style={{ perspective: 1000 }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: isFlipped ? 180 : 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 backface-hidden',
|
||||
frontClassName
|
||||
)}
|
||||
style={{ backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
{front}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 backface-hidden',
|
||||
backClassName
|
||||
)}
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
{back}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TiltCardProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
maxTilt?: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export function TiltCard({
|
||||
children,
|
||||
className = '',
|
||||
maxTilt = 10,
|
||||
scale = 1.02,
|
||||
...props
|
||||
}: TiltCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [rotateX, setRotateX] = useState(0);
|
||||
const [rotateY, setRotateY] = useState(0);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (!cardRef.current) return;
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const mouseX = e.clientX - centerX;
|
||||
const mouseY = e.clientY - centerY;
|
||||
|
||||
const rotateXValue = (mouseY / (rect.height / 2)) * -maxTilt;
|
||||
const rotateYValue = (mouseX / (rect.width / 2)) * maxTilt;
|
||||
|
||||
setRotateX(rotateXValue);
|
||||
setRotateY(rotateYValue);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setRotateX(0);
|
||||
setRotateY(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
className={cn('relative', className)}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
animate={{
|
||||
rotateX,
|
||||
rotateY,
|
||||
scale: rotateX || rotateY ? scale : 1,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GlowCardProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
export function GlowCard({
|
||||
children,
|
||||
className = '',
|
||||
glowColor = 'rgba(196, 30, 58, 0.15)',
|
||||
...props
|
||||
}: GlowCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [glowPosition, setGlowPosition] = useState({ x: 50, y: 50 });
|
||||
|
||||
const handleMouseMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (!cardRef.current) return;
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setGlowPosition({ x, y });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
whileHover={{ y: -4 }}
|
||||
onMouseMove={handleMouseMove}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-white border border-[#E5E5E5] rounded-xl',
|
||||
'transition-shadow duration-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 pointer-events-none opacity-0 hover:opacity-100 transition-opacity duration-300"
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${glowPosition.x}% ${glowPosition.y}%, ${glowColor} 0%, transparent 50%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExpandCardProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
expandedContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function ExpandCard({
|
||||
children,
|
||||
className = '',
|
||||
expandedContent,
|
||||
...props
|
||||
}: ExpandCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-white border border-[#E5E5E5] rounded-xl cursor-pointer',
|
||||
'transition-shadow duration-300',
|
||||
'hover:shadow-[0_12px_24px_rgba(28,28,28,0.08)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
{expandedContent && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{
|
||||
height: isExpanded ? 'auto' : 0,
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pb-6 border-t border-[#E5E5E5] pt-4">
|
||||
{expandedContent}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence, type Variants, type Transition } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
const defaultTransition: Transition = {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
};
|
||||
|
||||
export const fadeInVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const slideUpVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const slideRightVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -20,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const scaleVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 1.02,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const inkRevealPageVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
clipPath: 'circle(0% at 50% 50%)',
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
clipPath: 'circle(100% at 50% 50%)',
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
clipPath: 'circle(0% at 50% 50%)',
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const curtainVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: '100%',
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: '-100%',
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
variants?: Variants;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageTransition({
|
||||
children,
|
||||
variants = fadeInVariants,
|
||||
className = '',
|
||||
}: PageTransitionProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
variants={variants}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeTransition({ children, className = '' }: FadeTransitionProps) {
|
||||
return (
|
||||
<PageTransition variants={fadeInVariants} className={className}>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlideUpTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SlideUpTransition({ children, className = '' }: SlideUpTransitionProps) {
|
||||
return (
|
||||
<PageTransition variants={slideUpVariants} className={className}>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkRevealTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkRevealTransition({ children, className = '' }: InkRevealTransitionProps) {
|
||||
return (
|
||||
<PageTransition variants={inkRevealPageVariants} className={className}>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function SectionTransition({ children, className = '', delay = 0 }: SectionTransitionProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerChildrenProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
staggerDelay?: number;
|
||||
}
|
||||
|
||||
export function StaggerChildren({ children, className = '', staggerDelay = 0.1 }: StaggerChildrenProps) {
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
animation?: 'fade' | 'slide' | 'scale' | 'ink';
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function AnimatedSection({
|
||||
children,
|
||||
className = '',
|
||||
animation = 'fade',
|
||||
delay = 0,
|
||||
}: AnimatedSectionProps) {
|
||||
const getVariants = (): Variants => {
|
||||
switch (animation) {
|
||||
case 'slide':
|
||||
return {
|
||||
hidden: { opacity: 0, y: 40 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'scale':
|
||||
return {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'ink':
|
||||
return {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
filter: 'blur(10px)',
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={getVariants()}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingTransitionProps {
|
||||
isLoading: boolean;
|
||||
children: ReactNode;
|
||||
loadingComponent?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingTransition({
|
||||
isLoading,
|
||||
children,
|
||||
loadingComponent,
|
||||
className = '',
|
||||
}: LoadingTransitionProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={className}
|
||||
>
|
||||
{loadingComponent || (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
className="w-8 h-8 border-2 border-[#C41E3A] border-t-transparent rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export function SkeletonLoader({ className = '', animate = true }: SkeletonLoaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-[#F5F5F5] rounded-lg
|
||||
${animate ? 'animate-pulse' : ''}
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardSkeletonProps {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardSkeleton({ count = 1, className = '' }: CardSkeletonProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="bg-white border border-[#E5E5E5] rounded-xl p-6 space-y-4">
|
||||
<SkeletonLoader className="h-4 w-3/4" />
|
||||
<SkeletonLoader className="h-4 w-1/2" />
|
||||
<SkeletonLoader className="h-20 w-full" />
|
||||
<div className="flex gap-2">
|
||||
<SkeletonLoader className="h-8 w-20" />
|
||||
<SkeletonLoader className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, AnimatePresence, type HTMLMotionProps } from 'framer-motion';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const rippleButtonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[#1C1C1C] focus-visible:ring-offset-2 focus-visible:ring-offset-white relative overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-[#C41E3A] text-white hover:bg-[#A01830] hover:shadow-[0_8px_20px_rgba(196,30,58,0.35)]',
|
||||
secondary:
|
||||
'bg-[#1C1C1C] text-white hover:bg-[#0A0A0A] hover:shadow-[0_8px_20px_rgba(28,28,28,0.35)]',
|
||||
destructive:
|
||||
'bg-[#C41E3A] text-white hover:bg-[#A01830] focus-visible:ring-[#C41E3A]',
|
||||
outline:
|
||||
'border-2 border-[#1C1C1C] bg-transparent text-[#1C1C1C] hover:bg-[#F5F5F5] hover:shadow-[0_4px_12px_rgba(28,28,28,0.2)]',
|
||||
ghost:
|
||||
'text-[#3D3D3D] hover:bg-[#F5F5F5] hover:text-[#1C1C1C]',
|
||||
link:
|
||||
'text-[#1C1C1C] underline-offset-4 hover:underline hover:text-[#C41E3A]',
|
||||
seal:
|
||||
'bg-[#C41E3A] text-white font-semibold hover:bg-[#A01830] shadow-[0_6px_20px_rgba(196,30,58,0.3)]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-12 rounded-lg px-6 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface Ripple {
|
||||
x: number;
|
||||
y: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RippleButtonProps
|
||||
extends VariantProps<typeof rippleButtonVariants> {
|
||||
rippleColor?: string;
|
||||
rippleDuration?: number;
|
||||
children?: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RippleButton = React.forwardRef<HTMLButtonElement, RippleButtonProps>(
|
||||
({ className, variant, size, rippleColor, rippleDuration = 800, onClick, children, disabled, ...props }, ref) => {
|
||||
const [ripples, setRipples] = React.useState<Ripple[]>([]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
const button = e.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = Date.now();
|
||||
|
||||
setRipples((prev) => [...prev, { x, y, id }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setRipples((prev) => prev.filter((r) => r.id !== id));
|
||||
}, rippleDuration);
|
||||
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
const getRippleColor = () => {
|
||||
if (rippleColor) return rippleColor;
|
||||
if (variant === 'outline' || variant === 'ghost' || variant === 'link') {
|
||||
return 'rgba(196, 30, 58, 0.2)';
|
||||
}
|
||||
return 'rgba(255, 255, 255, 0.4)';
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
whileHover={disabled ? {} : { scale: 1.03, y: -3 }}
|
||||
whileTap={disabled ? {} : { scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
className={cn(rippleButtonVariants({ variant, size, className }))}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<AnimatePresence>
|
||||
{ripples.map((ripple) => (
|
||||
<motion.span
|
||||
key={ripple.id}
|
||||
initial={{ scale: 0, opacity: 0.8 }}
|
||||
animate={{ scale: 5, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: rippleDuration / 1000, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute w-6 h-6 rounded-full pointer-events-none"
|
||||
style={{
|
||||
left: ripple.x - 12,
|
||||
top: ripple.y - 12,
|
||||
backgroundColor: getRippleColor(),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
);
|
||||
RippleButton.displayName = 'RippleButton';
|
||||
|
||||
export interface SealButtonProps extends VariantProps<typeof rippleButtonVariants> {
|
||||
children?: React.ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SealButton = React.forwardRef<HTMLButtonElement, SealButtonProps>(
|
||||
({ className, variant = 'seal', size, onClick, children, disabled, ...props }, ref) => {
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const [showInk, setShowInk] = React.useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
setIsPressed(true);
|
||||
setShowInk(true);
|
||||
setTimeout(() => setIsPressed(false), 600);
|
||||
setTimeout(() => setShowInk(false), 800);
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
initial={{ scale: 1, rotate: 0 }}
|
||||
whileHover={disabled ? {} : { scale: 1.06, y: -2 }}
|
||||
whileTap={disabled ? {} : { scale: 0.94, rotate: -3 }}
|
||||
animate={
|
||||
isPressed
|
||||
? {
|
||||
scale: [1, 1.15, 0.95, 1.02, 1],
|
||||
rotate: [0, -8, 8, -3, 0],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 12,
|
||||
}}
|
||||
className={cn(
|
||||
rippleButtonVariants({ variant, size, className }),
|
||||
'seal-stamp'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{showInk && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0.6 }}
|
||||
animate={{ scale: 3, opacity: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-white/20" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="relative z-10 inline-flex items-center gap-1">{children}</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
);
|
||||
SealButton.displayName = 'SealButton';
|
||||
|
||||
export { RippleButton, SealButton, rippleButtonVariants };
|
||||
@@ -0,0 +1,438 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useScroll, useTransform, useSpring, type MotionValue, type Variants, type HTMLMotionProps } from 'framer-motion';
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
|
||||
export const scrollRevealVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const inkRevealVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
filter: 'blur(10px)',
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const slideInLeftVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: -50,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const slideInRightVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: 50,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ScrollRevealProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
variants?: Variants;
|
||||
once?: boolean;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className = '',
|
||||
delay = 0,
|
||||
variants = scrollRevealVariants,
|
||||
once = true,
|
||||
threshold = 0.1,
|
||||
...props
|
||||
}: ScrollRevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'start center'],
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, threshold], [0, 1]);
|
||||
const y = useTransform(scrollYProgress, [0, threshold], [50, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, threshold], [0.95, 1]);
|
||||
|
||||
const smoothOpacity = useSpring(opacity, { stiffness: 100, damping: 30 });
|
||||
const smoothY = useSpring(y, { stiffness: 100, damping: 30 });
|
||||
const smoothScale = useSpring(scale, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity: smoothOpacity,
|
||||
y: smoothY,
|
||||
scale: smoothScale,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||
viewport={{ once, amount: threshold }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParallaxSectionProps extends HTMLMotionProps<'section'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function ParallaxSection({
|
||||
children,
|
||||
className = '',
|
||||
speed = 0.5,
|
||||
...props
|
||||
}: ParallaxSectionProps) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], ['0%', `${speed * 100}%`]);
|
||||
const smoothY = useSpring(y, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.section ref={ref} style={{ y: smoothY }} className={className} {...props}>
|
||||
{children}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParallaxImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function ParallaxImage({ src, alt, className = '', speed = 0.3 }: ParallaxImageProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], [`${-speed * 50}%`, `${speed * 50}%`]);
|
||||
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [1.1, 1, 1.1]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`overflow-hidden ${className}`}>
|
||||
<motion.img
|
||||
src={src}
|
||||
alt={alt}
|
||||
style={{ y, scale }}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScaleOnScrollProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
minScale?: number;
|
||||
maxScale?: number;
|
||||
}
|
||||
|
||||
export function ScaleOnScroll({
|
||||
children,
|
||||
className = '',
|
||||
minScale = 0.8,
|
||||
maxScale = 1,
|
||||
...props
|
||||
}: ScaleOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [minScale, maxScale]);
|
||||
const smoothScale = useSpring(scale, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} style={{ scale: smoothScale }} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeOnScrollProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
export function FadeOnScroll({
|
||||
children,
|
||||
className = '',
|
||||
direction = 'up',
|
||||
distance = 50,
|
||||
...props
|
||||
}: FadeOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const getTransform = () => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return useTransform(scrollYProgress, [0, 1], [distance, 0]);
|
||||
case 'down':
|
||||
return useTransform(scrollYProgress, [0, 1], [-distance, 0]);
|
||||
case 'left':
|
||||
return useTransform(scrollYProgress, [0, 1], [distance, 0]);
|
||||
case 'right':
|
||||
return useTransform(scrollYProgress, [0, 1], [-distance, 0]);
|
||||
default:
|
||||
return useTransform(scrollYProgress, [0, 1], [distance, 0]);
|
||||
}
|
||||
};
|
||||
|
||||
const transform = getTransform();
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5], [0, 1]);
|
||||
|
||||
const smoothTransform = useSpring(transform, { stiffness: 100, damping: 30 });
|
||||
const smoothOpacity = useSpring(opacity, { stiffness: 100, damping: 30 });
|
||||
|
||||
const style =
|
||||
direction === 'left' || direction === 'right' ? { x: smoothTransform, opacity: smoothOpacity } : { y: smoothTransform, opacity: smoothOpacity };
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} style={style} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerRevealProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
staggerDelay?: number;
|
||||
containerDelay?: number;
|
||||
}
|
||||
|
||||
export function StaggerReveal({
|
||||
children,
|
||||
className = '',
|
||||
staggerDelay = 0.1,
|
||||
containerDelay = 0,
|
||||
...props
|
||||
}: StaggerRevealProps) {
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: containerDelay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerItemProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StaggerItem({ children, className = '', ...props }: StaggerItemProps) {
|
||||
const itemVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div variants={itemVariants} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TextRevealProps extends HTMLMotionProps<'div'> {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function TextReveal({ text, className = '', delay = 0, ...props }: TextRevealProps) {
|
||||
const words = text.split(' ');
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: delay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wordVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={containerVariants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={wordVariants}
|
||||
className="inline-block mr-2"
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
className?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function ProgressIndicator({ className = '', color = '#C41E3A' }: ProgressIndicatorProps) {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`fixed top-0 left-0 right-0 h-1 origin-left z-50 ${className}`}
|
||||
style={{ scaleX, backgroundColor: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScrollTriggeredCounterProps {
|
||||
end: number;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrollTriggeredCounter({
|
||||
end,
|
||||
duration = 2,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
className = '',
|
||||
}: ScrollTriggeredCounterProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const displayNumber = useTransform(scrollYProgress, [0, 1], [0, end]);
|
||||
const rounded = useTransform(displayNumber, (latest) => Math.round(latest));
|
||||
|
||||
return (
|
||||
<motion.span ref={ref} className={className}>
|
||||
{prefix}
|
||||
<motion.span>{rounded}</motion.span>
|
||||
{suffix}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useInView, useSpring, useTransform, type MotionValue, type Variants, type HTMLMotionProps } from 'framer-motion';
|
||||
import { useRef, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
export const inkVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
filter: 'blur(10px)',
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const sealStampVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 1.5,
|
||||
rotate: -15,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
mass: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const brushStrokeVariants: Variants = {
|
||||
hidden: {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
pathLength: {
|
||||
duration: 1.5,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const fadeUpVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const staggerContainerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const staggerItemVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface InkRevealProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkReveal({ children, delay = 0, className = '', ...props }: InkRevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
variants={inkVariants}
|
||||
transition={{ delay }}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SealStampProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SealStamp({ children, delay = 0, className = '', ...props }: SealStampProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
variants={sealStampVariants}
|
||||
transition={{ delay }}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeUpProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeUp({ children, delay = 0, duration = 0.6, className = '', ...props }: FadeUpProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerContainerProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
staggerDelay?: number;
|
||||
}
|
||||
|
||||
export function StaggerContainer({ children, className = '', staggerDelay = 0.1, ...props }: StaggerContainerProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerItemProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StaggerItem({ children, className = '', ...props }: StaggerItemProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={staggerItemVariants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RippleButtonProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
rippleColor?: string;
|
||||
}
|
||||
|
||||
export function RippleButton({ children, onClick, className = '', rippleColor = 'rgba(196, 30, 58, 0.3)' }: RippleButtonProps) {
|
||||
const [ripples, setRipples] = useState<Array<{ x: number; y: number; id: number }>>([]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const button = e.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const id = Date.now();
|
||||
|
||||
setRipples((prev) => [...prev, { x, y, id }]);
|
||||
|
||||
setTimeout(() => {
|
||||
setRipples((prev) => prev.filter((r) => r.id !== id));
|
||||
}, 600);
|
||||
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={handleClick}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
>
|
||||
{children}
|
||||
{ripples.map((ripple) => (
|
||||
<motion.span
|
||||
key={ripple.id}
|
||||
initial={{ scale: 0, opacity: 1 }}
|
||||
animate={{ scale: 4, opacity: 0 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="absolute w-4 h-4 rounded-full pointer-events-none"
|
||||
style={{
|
||||
left: ripple.x - 8,
|
||||
top: ripple.y - 8,
|
||||
backgroundColor: rippleColor,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hoverScale?: number;
|
||||
hoverShadow?: string;
|
||||
}
|
||||
|
||||
export function InkCard({ children, className = '', hoverScale = 1.02, hoverShadow = '0 20px 40px rgba(28, 28, 28, 0.1)' }: InkCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{
|
||||
scale: hoverScale,
|
||||
boxShadow: hoverShadow,
|
||||
y: -4,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useParallax(value: MotionValue<number>, distance: number) {
|
||||
return useTransform(value, [0, 1], [-distance, distance]);
|
||||
}
|
||||
|
||||
export function useSmoothSpring(value: MotionValue<number>, springConfig = { stiffness: 100, damping: 30, restDelta: 0.001 }) {
|
||||
return useSpring(value, springConfig);
|
||||
}
|
||||
|
||||
interface CountUpProps {
|
||||
end: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CountUp({ end, duration = 2000, delay = 0, prefix = '', suffix = '', className = '' }: CountUpProps) {
|
||||
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(end);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = (now - startTime) / duration;
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const currentValue = Math.floor(easeOutQuart * end);
|
||||
|
||||
setCount(currentValue);
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [isInView, end, 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 TypewriterProps {
|
||||
text: string;
|
||||
delay?: number;
|
||||
speed?: number;
|
||||
className?: string;
|
||||
cursorClassName?: string;
|
||||
}
|
||||
|
||||
export function Typewriter({ text, delay = 0, speed = 50, className = '', cursorClassName = '' }: TypewriterProps) {
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
const startTyping = setTimeout(() => {
|
||||
setIsTyping(true);
|
||||
let currentIndex = 0;
|
||||
|
||||
const typeInterval = setInterval(() => {
|
||||
if (currentIndex < text.length) {
|
||||
setDisplayText(text.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
} else {
|
||||
clearInterval(typeInterval);
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => clearInterval(typeInterval);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(startTyping);
|
||||
}, [isInView, text, delay, speed]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
{displayText}
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ duration: 0.5, repeat: Infinity, repeatType: 'reverse' }}
|
||||
className={cursorClassName}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
|
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface FloatingElementProps {
|
||||
children: ReactNode;
|
||||
amplitude?: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FloatingElement({ children, amplitude = 10, duration = 3, delay = 0, className = '' }: FloatingElementProps) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
y: [-amplitude, amplitude, -amplitude],
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
delay,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PulseElementProps {
|
||||
children: ReactNode;
|
||||
scale?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PulseElement({ children, scale = 1.05, duration = 2, className = '' }: PulseElementProps) {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, scale, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InkDropSVG({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="currentColor"
|
||||
variants={{
|
||||
hidden: { scale: 0, opacity: 0 },
|
||||
visible: {
|
||||
scale: [0, 1.2, 1],
|
||||
opacity: [0, 1, 1],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface GradientTextProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
colors?: string[];
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function GradientText({
|
||||
children,
|
||||
className = '',
|
||||
colors = ['#C41E3A', '#E04A68', '#C41E3A'],
|
||||
duration = 3
|
||||
}: GradientTextProps) {
|
||||
return (
|
||||
<motion.span
|
||||
className={className}
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${colors.join(', ')})`,
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
animate={{
|
||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SplitTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
staggerDelay?: number;
|
||||
}
|
||||
|
||||
export function SplitText({ text, className = '', delay = 0, staggerDelay = 0.03 }: SplitTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once: true });
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: delay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const child: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
rotateX: -90,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
transition: {
|
||||
type: 'spring' as const,
|
||||
stiffness: 100,
|
||||
damping: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={ref}
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
className={className}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{text.split('').map((char, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={child}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
interface GlitchTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GlitchText({ text, className = '' }: GlitchTextProps) {
|
||||
return (
|
||||
<span className={`relative ${className}`}>
|
||||
<span className="relative z-10">{text}</span>
|
||||
<motion.span
|
||||
className="absolute top-0 left-0 text-[#C41E3A] z-0"
|
||||
animate={{ x: [-2, 2, -2], opacity: [0.8, 0.4, 0.8] }}
|
||||
transition={{ duration: 0.3, repeat: Infinity }}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="absolute top-0 left-0 text-[#1C1C1C] z-0"
|
||||
animate={{ x: [2, -2, 2], opacity: [0.8, 0.4, 0.8] }}
|
||||
transition={{ duration: 0.3, repeat: Infinity, delay: 0.15 }}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
strength?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MagneticButton({ children, className = '', strength = 0.3, onClick }: MagneticButtonProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const x = (e.clientX - centerX) * strength;
|
||||
const y = (e.clientY - centerY) * strength;
|
||||
setPosition({ x, y });
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
animate={{ x: position.x, y: position.y }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15, mass: 0.1 }}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlurRevealProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function BlurReveal({ children, className = '', delay = 0 }: BlurRevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ filter: 'blur(20px)', opacity: 0, scale: 1.1 }}
|
||||
animate={isInView ? { filter: 'blur(0px)', opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 1, delay, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WaveTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function WaveText({ text, className = '', delay = 0 }: WaveTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const isInView = useInView(ref, { once: true });
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: delay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const child: Variants = {
|
||||
hidden: { y: 0 },
|
||||
visible: {
|
||||
y: [0, -10, 0],
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: 'easeInOut' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={ref}
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
className={className}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
{text.split('').map((char, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={child}
|
||||
style={{ display: 'inline-block' }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
interface RotatingBorderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
borderWidth?: number;
|
||||
duration?: number;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
export function RotatingBorder({
|
||||
children,
|
||||
className = '',
|
||||
borderWidth = 2,
|
||||
duration = 4,
|
||||
colors = ['#C41E3A', '#1C1C1C', '#C41E3A']
|
||||
}: RotatingBorderProps) {
|
||||
const gradient = `conic-gradient(from 0deg, ${colors.join(', ')})`;
|
||||
|
||||
return (
|
||||
<div className={`relative p-[${borderWidth}px] rounded-lg overflow-hidden ${className}`}>
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{ background: gradient }}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
<div className="relative bg-white rounded-md">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShimmerButtonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
shimmerColor?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ShimmerButton({ children, className = '', shimmerColor = 'rgba(255,255,255,0.3)', onClick }: ShimmerButtonProps) {
|
||||
return (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`relative overflow-hidden ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent, ${shimmerColor}, transparent)`,
|
||||
backgroundSize: '200% 100%',
|
||||
}}
|
||||
animate={{
|
||||
backgroundPosition: ['-200% 0', '200% 0'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
repeatDelay: 2,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
<span className="relative z-10">{children}</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkSplashProps {
|
||||
className?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function InkSplash({ className = '', color = '#C41E3A', size = 100 }: InkSplashProps) {
|
||||
const pathVariants: Variants = {
|
||||
hidden: { pathLength: 0, opacity: 0 },
|
||||
visible: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
transition: { duration: 1.5, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<motion.path
|
||||
d="M50 10 C30 20 20 40 25 60 C30 80 50 90 50 90 C50 90 70 80 75 60 C80 40 70 20 50 10"
|
||||
fill={color}
|
||||
variants={pathVariants}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface CounterWithEffectProps {
|
||||
end: number;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
effect?: 'bounce' | 'slide' | 'flip';
|
||||
}
|
||||
|
||||
export function CounterWithEffect({
|
||||
end,
|
||||
duration = 2000,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
className = '',
|
||||
effect = 'bounce'
|
||||
}: CounterWithEffectProps) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [prevCount, setPrevCount] = 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();
|
||||
const animate = () => {
|
||||
const progress = Math.min((Date.now() - startTime) / duration, 1);
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const newCount = Math.floor(easeOutQuart * end);
|
||||
|
||||
if (newCount !== count) {
|
||||
setPrevCount(count);
|
||||
setCount(newCount);
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
}, [isInView, end, duration, count]);
|
||||
|
||||
const effectVariants = {
|
||||
bounce: {
|
||||
initial: { y: 0 },
|
||||
animate: { y: [0, -10, 0] },
|
||||
transition: { duration: 0.3 },
|
||||
},
|
||||
slide: {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
flip: {
|
||||
initial: { rotateX: 90 },
|
||||
animate: { rotateX: 0 },
|
||||
transition: { duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={ref}
|
||||
key={count}
|
||||
initial={effectVariants[effect].initial}
|
||||
animate={effectVariants[effect].animate}
|
||||
transition={effectVariants[effect].transition}
|
||||
className={className}
|
||||
>
|
||||
{prefix}{count}{suffix}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user