perf(motion): 清理末批 P0 —— 移除循环动画 + 删除死代码
按用户决策收尾 Task #16,P0 硬违规 13 → 0: - 移除 3 处循环动画(均违反 CONTEXT.md「禁循环动画」) detail-hero 徽章 Sparkles 旋转 4s、滚动箭头浮动 2s、brand-visuals 半径脉冲 4s → motion.* 改静态元素,保留视觉语义但不再循环 - 删除死代码 src/lib/animations.tsx(923 行,全站零引用,含 10 处 P0 + spring variants) 同步删除 src/lib/animations.test.tsx - 级联修复:brand-visuals 因删 guard 冗余的 shouldReduceMotion 声明一并移除 验证: - 审计 P0 13 → 0,P1 163→156,P2 28→25(死代码移除导致分母下降) - tsc --noEmit 0 error;eslint 0 error - 视觉基线不变:时长/循环改动在 reducedMotion+animations disabled 下不改变最终像素
This commit is contained in:
@@ -83,12 +83,9 @@ export function DetailHero({
|
||||
borderColor: `${theme.accentColor}25`,
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: [0, 360] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: 'linear' as const }}
|
||||
>
|
||||
<span>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</motion.span>
|
||||
</span>
|
||||
{badge || theme.badge}
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -192,13 +189,11 @@ export function DetailHero({
|
||||
transition={{ delay: 1.5, duration: 0.3 }}
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' as const }}
|
||||
<div
|
||||
className="text-text-muted cursor-pointer hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<ChevronDown className="w-6 h-6" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5,8 +5,6 @@ import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
// ===== 抽象几何装饰 - 用于各 section 背景 =====
|
||||
export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circles' | 'lines' | 'grid' | 'waves' }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
if (variant === 'circles') {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
@@ -14,14 +12,10 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
|
||||
<circle cx="100" cy="100" r="80" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||
<circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||
{!shouldReduceMotion && (
|
||||
<motion.circle
|
||||
cx="100" cy="100" r="20"
|
||||
fill="rgba(var(--color-brand-rgb, 196, 30, 58), 0.1)"
|
||||
animate={{ r: [20, 35, 20], opacity: [0.1, 0.05, 0.1] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx="100" cy="100" r="20"
|
||||
fill="rgba(var(--color-brand-rgb, 196, 30, 58), 0.1)"
|
||||
/>
|
||||
</svg>
|
||||
<svg className="absolute bottom-[15%] left-[8%] w-48 h-48 opacity-[0.03]" viewBox="0 0 200 200">
|
||||
<rect x="40" y="40" width="120" height="120" rx="20" fill="none" stroke="currentColor" className="text-[var(--color-text-subtle)]" strokeWidth="0.5" transform="rotate(15 100 100)" />
|
||||
|
||||
@@ -1,841 +0,0 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, initial, animate, variants, className, whileHover, whileTap, transition, ...props }: any) => (
|
||||
<div
|
||||
data-testid="motion-div"
|
||||
data-initial={JSON.stringify(initial)}
|
||||
data-animate={JSON.stringify(animate)}
|
||||
data-variants={JSON.stringify(variants)}
|
||||
data-while-hover={JSON.stringify(whileHover)}
|
||||
data-while-tap={JSON.stringify(whileTap)}
|
||||
data-transition={JSON.stringify(transition)}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
button: ({ children, onClick, className, whileHover, whileTap, ...props }: any) => (
|
||||
<button
|
||||
data-testid="motion-button"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
data-while-hover={JSON.stringify(whileHover)}
|
||||
data-while-tap={JSON.stringify(whileTap)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
span: ({ children, className, animate, initial, transition, ...props }: any) => (
|
||||
<span
|
||||
data-testid="motion-span"
|
||||
className={className}
|
||||
data-animate={JSON.stringify(animate)}
|
||||
data-initial={JSON.stringify(initial)}
|
||||
data-transition={JSON.stringify(transition)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
svg: ({ children, className, ...props }: any) => (
|
||||
<svg data-testid="motion-svg" className={className} {...props}>
|
||||
{children}
|
||||
</svg>
|
||||
),
|
||||
circle: ({ variants, ...props }: any) => (
|
||||
<circle data-testid="motion-circle" data-variants={JSON.stringify(variants)} {...props} />
|
||||
),
|
||||
path: ({ variants, ...props }: any) => (
|
||||
<path data-testid="motion-path" data-variants={JSON.stringify(variants)} {...props} />
|
||||
),
|
||||
a: ({ children, className, ...props }: any) => (
|
||||
<a data-testid="motion-a" className={className} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
h1: ({ children, className, ...props }: any) => (
|
||||
<h1 data-testid="motion-h1" className={className} {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, className, ...props }: any) => (
|
||||
<h2 data-testid="motion-h2" className={className} {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
p: ({ children, className, ...props }: any) => (
|
||||
<p data-testid="motion-p" className={className} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
AnimatePresence: ({ children }: any) => <>{children}</>,
|
||||
useInView: jest.fn(() => true),
|
||||
useSpring: jest.fn((value) => value),
|
||||
useTransform: jest.fn((value) => value),
|
||||
useMotionValue: jest.fn((initial) => ({
|
||||
get: () => initial,
|
||||
set: jest.fn(),
|
||||
onChange: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Animation Variants', () => {
|
||||
describe('inkVariants', () => {
|
||||
it('should have correct hidden state', async () => {
|
||||
const { inkVariants } = await import('./animations');
|
||||
expect(inkVariants.hidden).toEqual({
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
filter: 'blur(10px)',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct visible state', async () => {
|
||||
const { inkVariants } = await import('./animations');
|
||||
expect(inkVariants.visible).toHaveProperty('opacity', 1);
|
||||
expect(inkVariants.visible).toHaveProperty('scale', 1);
|
||||
expect(inkVariants.visible).toHaveProperty('filter', 'blur(0px)');
|
||||
});
|
||||
|
||||
it('should have correct transition configuration', async () => {
|
||||
const { inkVariants } = await import('./animations');
|
||||
const transition = (inkVariants.visible as any)?.transition;
|
||||
expect(transition.duration).toBe(0.8);
|
||||
expect(transition.ease).toEqual([0.16, 1, 0.3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sealStampVariants', () => {
|
||||
it('should have correct hidden state', async () => {
|
||||
const { sealStampVariants } = await import('./animations');
|
||||
expect(sealStampVariants.hidden).toEqual({
|
||||
opacity: 0,
|
||||
scale: 1.5,
|
||||
rotate: -15,
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct visible state', async () => {
|
||||
const { sealStampVariants } = await import('./animations');
|
||||
expect(sealStampVariants.visible).toHaveProperty('opacity', 1);
|
||||
expect(sealStampVariants.visible).toHaveProperty('scale', 1);
|
||||
expect(sealStampVariants.visible).toHaveProperty('rotate', 0);
|
||||
});
|
||||
|
||||
it('should use spring animation', async () => {
|
||||
const { sealStampVariants } = await import('./animations');
|
||||
const transition = (sealStampVariants.visible as any)?.transition;
|
||||
expect(transition.type).toBe('spring');
|
||||
expect(transition.stiffness).toBe(300);
|
||||
expect(transition.damping).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('brushStrokeVariants', () => {
|
||||
it('should have correct hidden state', async () => {
|
||||
const { brushStrokeVariants } = await import('./animations');
|
||||
expect(brushStrokeVariants.hidden).toEqual({
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct visible state', async () => {
|
||||
const { brushStrokeVariants } = await import('./animations');
|
||||
expect(brushStrokeVariants.visible).toHaveProperty('pathLength', 1);
|
||||
expect(brushStrokeVariants.visible).toHaveProperty('opacity', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fadeUpVariants', () => {
|
||||
it('should have correct hidden state', async () => {
|
||||
const { fadeUpVariants } = await import('./animations');
|
||||
expect(fadeUpVariants.hidden).toEqual({
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct visible state', async () => {
|
||||
const { fadeUpVariants } = await import('./animations');
|
||||
expect(fadeUpVariants.visible).toHaveProperty('opacity', 1);
|
||||
expect(fadeUpVariants.visible).toHaveProperty('y', 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staggerContainerVariants', () => {
|
||||
it('should have staggerChildren configured', async () => {
|
||||
const { staggerContainerVariants } = await import('./animations');
|
||||
const transition = (staggerContainerVariants.visible as any)?.transition;
|
||||
expect(transition.staggerChildren).toBe(0.1);
|
||||
expect(transition.delayChildren).toBe(0.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staggerItemVariants', () => {
|
||||
it('should have correct hidden state', async () => {
|
||||
const { staggerItemVariants } = await import('./animations');
|
||||
expect(staggerItemVariants.hidden).toEqual({
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
scale: 0.95,
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct visible state', async () => {
|
||||
const { staggerItemVariants } = await import('./animations');
|
||||
expect(staggerItemVariants.visible).toHaveProperty('opacity', 1);
|
||||
expect(staggerItemVariants.visible).toHaveProperty('y', 0);
|
||||
expect(staggerItemVariants.visible).toHaveProperty('scale', 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Animation Components', () => {
|
||||
describe('InkReveal', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { InkReveal } = await import('./animations');
|
||||
render(<InkReveal>Test Content</InkReveal>);
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { InkReveal } = await import('./animations');
|
||||
render(<InkReveal className="custom-class">Test</InkReveal>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
expect(element).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should use inkVariants', async () => {
|
||||
const { InkReveal, inkVariants } = await import('./animations');
|
||||
render(<InkReveal>Test</InkReveal>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
||||
expect(variants).toEqual(inkVariants);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SealStamp', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { SealStamp } = await import('./animations');
|
||||
render(<SealStamp>Seal Content</SealStamp>);
|
||||
expect(screen.getByText('Seal Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { SealStamp } = await import('./animations');
|
||||
render(<SealStamp className="seal-class">Test</SealStamp>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
expect(element).toHaveClass('seal-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FadeUp', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { FadeUp } = await import('./animations');
|
||||
render(<FadeUp>Fade Content</FadeUp>);
|
||||
expect(screen.getByText('Fade Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom duration', async () => {
|
||||
const { FadeUp } = await import('./animations');
|
||||
render(<FadeUp duration={1.2}>Test</FadeUp>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
||||
expect(variants.visible.transition.duration).toBe(1.2);
|
||||
});
|
||||
|
||||
it('should apply delay prop', async () => {
|
||||
const { FadeUp } = await import('./animations');
|
||||
render(<FadeUp delay={0.3}>Test</FadeUp>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
||||
expect(variants.visible.transition.delay).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StaggerContainer', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { StaggerContainer } = await import('./animations');
|
||||
render(
|
||||
<StaggerContainer>
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</StaggerContainer>
|
||||
);
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom staggerDelay', async () => {
|
||||
const { StaggerContainer } = await import('./animations');
|
||||
render(<StaggerContainer staggerDelay={0.2}>Test</StaggerContainer>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
||||
expect(variants.visible.transition.staggerChildren).toBe(0.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StaggerItem', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { StaggerItem } = await import('./animations');
|
||||
render(<StaggerItem>Item Content</StaggerItem>);
|
||||
expect(screen.getByText('Item Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RippleButton', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { RippleButton } = await import('./animations');
|
||||
render(<RippleButton>Click Me</RippleButton>);
|
||||
expect(screen.getByText('Click Me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle click events', async () => {
|
||||
const { RippleButton } = await import('./animations');
|
||||
const handleClick = jest.fn();
|
||||
render(<RippleButton onClick={handleClick}>Click Me</RippleButton>);
|
||||
|
||||
const button = screen.getByTestId('motion-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { RippleButton } = await import('./animations');
|
||||
render(<RippleButton className="custom-button">Test</RippleButton>);
|
||||
const element = screen.getByTestId('motion-button');
|
||||
expect(element).toHaveClass('custom-button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CountUp', () => {
|
||||
it('should render with prefix and suffix', async () => {
|
||||
const { CountUp } = await import('./animations');
|
||||
render(<CountUp end={100} prefix="$" suffix="%" />);
|
||||
expect(screen.getByText(/\$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { CountUp } = await import('./animations');
|
||||
render(<CountUp end={100} className="counter-class" />);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toHaveClass('counter-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Typewriter', () => {
|
||||
it('should render component correctly', async () => {
|
||||
const { Typewriter } = await import('./animations');
|
||||
render(<Typewriter text="Hello" />);
|
||||
const cursor = screen.getByText('|');
|
||||
expect(cursor).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { Typewriter } = await import('./animations');
|
||||
render(<Typewriter text="Test" className="typewriter-class" />);
|
||||
const container = screen.getByText('|').closest('.typewriter-class');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FloatingElement', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { FloatingElement } = await import('./animations');
|
||||
render(<FloatingElement>Floating Content</FloatingElement>);
|
||||
expect(screen.getByText('Floating Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom amplitude', async () => {
|
||||
const { FloatingElement } = await import('./animations');
|
||||
render(<FloatingElement amplitude={20}>Test</FloatingElement>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
||||
expect(animate.y).toEqual([-20, 20, -20]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PulseElement', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { PulseElement } = await import('./animations');
|
||||
render(<PulseElement>Pulse Content</PulseElement>);
|
||||
expect(screen.getByText('Pulse Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom scale', async () => {
|
||||
const { PulseElement } = await import('./animations');
|
||||
render(<PulseElement scale={1.1}>Test</PulseElement>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
||||
expect(animate.scale).toEqual([1, 1.1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradientText', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { GradientText } = await import('./animations');
|
||||
render(<GradientText>Gradient Text</GradientText>);
|
||||
expect(screen.getByText('Gradient Text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom colors', async () => {
|
||||
const { GradientText } = await import('./animations');
|
||||
render(<GradientText colors={['#ff0000', '#00ff00', '#0000ff']}>Test</GradientText>);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SplitText', () => {
|
||||
it('should render text correctly', async () => {
|
||||
const { SplitText } = await import('./animations');
|
||||
render(<SplitText text="Hi" />);
|
||||
expect(screen.getByText('H')).toBeInTheDocument();
|
||||
expect(screen.getByText('i')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { SplitText } = await import('./animations');
|
||||
render(<SplitText text="Test" className="split-class" />);
|
||||
const elements = screen.getAllByTestId('motion-span');
|
||||
const parentElement = elements.find(el => el.classList.contains('split-class'));
|
||||
expect(parentElement).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlitchText', () => {
|
||||
it('should render text correctly', async () => {
|
||||
const { GlitchText } = await import('./animations');
|
||||
render(<GlitchText text="Glitch" />);
|
||||
const glitchElements = screen.getAllByText('Glitch');
|
||||
expect(glitchElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { GlitchText } = await import('./animations');
|
||||
render(<GlitchText text="Test" className="glitch-class" />);
|
||||
const testElements = screen.getAllByText('Test');
|
||||
const container = testElements[0]?.closest('.glitch-class');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MagneticButton', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { MagneticButton } = await import('./animations');
|
||||
render(<MagneticButton>Magnetic</MagneticButton>);
|
||||
expect(screen.getByText('Magnetic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle click events', async () => {
|
||||
const { MagneticButton } = await import('./animations');
|
||||
const handleClick = jest.fn();
|
||||
render(<MagneticButton onClick={handleClick}>Click</MagneticButton>);
|
||||
|
||||
const element = screen.getByText('Click');
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { MagneticButton } = await import('./animations');
|
||||
render(<MagneticButton className="magnetic-class">Test</MagneticButton>);
|
||||
const element = screen.getByText('Test').closest('.magnetic-class');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom strength', async () => {
|
||||
const { MagneticButton } = await import('./animations');
|
||||
render(<MagneticButton strength={0.5}>Test</MagneticButton>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlurReveal', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { BlurReveal } = await import('./animations');
|
||||
render(<BlurReveal>Blur Content</BlurReveal>);
|
||||
expect(screen.getByText('Blur Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { BlurReveal } = await import('./animations');
|
||||
render(<BlurReveal className="blur-class">Test</BlurReveal>);
|
||||
const element = screen.getByTestId('motion-div');
|
||||
expect(element).toHaveClass('blur-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaveText', () => {
|
||||
it('should render text correctly', async () => {
|
||||
const { WaveText } = await import('./animations');
|
||||
render(<WaveText text="Hi" />);
|
||||
expect(screen.getByText('H')).toBeInTheDocument();
|
||||
expect(screen.getByText('i')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShimmerButton', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { ShimmerButton } = await import('./animations');
|
||||
render(<ShimmerButton>Shimmer</ShimmerButton>);
|
||||
expect(screen.getByText('Shimmer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle click events', async () => {
|
||||
const { ShimmerButton } = await import('./animations');
|
||||
const handleClick = jest.fn();
|
||||
render(<ShimmerButton onClick={handleClick}>Click</ShimmerButton>);
|
||||
|
||||
const button = screen.getByTestId('motion-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply custom shimmerColor', async () => {
|
||||
const { ShimmerButton } = await import('./animations');
|
||||
render(<ShimmerButton shimmerColor="rgba(0,0,0,0.5)">Test</ShimmerButton>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RotatingBorder', () => {
|
||||
it('should render children correctly', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder>Border Content</RotatingBorder>);
|
||||
expect(screen.getByText('Border Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder className="rotating-class">Test</RotatingBorder>);
|
||||
const container = screen.getByText('Test').closest('.rotating-class');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom borderWidth', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom colors', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder colors={['red', 'blue', 'green']}>Test</RotatingBorder>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept custom duration', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CounterWithEffect', () => {
|
||||
it('should render with prefix and suffix', async () => {
|
||||
const { CounterWithEffect } = await import('./animations');
|
||||
render(<CounterWithEffect end={100} prefix="$" suffix="%" />);
|
||||
expect(screen.getByText(/100|\$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { CounterWithEffect } = await import('./animations');
|
||||
render(<CounterWithEffect end={100} className="counter-eff-class" />);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toHaveClass('counter-eff-class');
|
||||
});
|
||||
|
||||
it('should accept bounce effect', async () => {
|
||||
const { CounterWithEffect } = await import('./animations');
|
||||
render(<CounterWithEffect end={100} effect="bounce" />);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toBeInTheDocument();
|
||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
||||
expect(animate).toEqual({ y: [0, -10, 0] });
|
||||
});
|
||||
|
||||
it('should accept slide effect', async () => {
|
||||
const { CounterWithEffect } = await import('./animations');
|
||||
render(<CounterWithEffect end={100} effect="slide" />);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toBeInTheDocument();
|
||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
||||
expect(animate).toEqual({ y: 0, opacity: 1 });
|
||||
});
|
||||
|
||||
it('should accept flip effect', async () => {
|
||||
const { CounterWithEffect } = await import('./animations');
|
||||
render(<CounterWithEffect end={100} effect="flip" />);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toBeInTheDocument();
|
||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
||||
expect(animate).toEqual({ rotateX: 0 });
|
||||
});
|
||||
|
||||
it('should render initial count value', async () => {
|
||||
const { CounterWithEffect } = await import('./animations');
|
||||
render(<CounterWithEffect end={42} />);
|
||||
// Component renders with end value, then useEffect resets to 0
|
||||
// due to useInView mock returning true
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enhanced Component Tests', () => {
|
||||
describe('CountUp', () => {
|
||||
it('should render with custom duration', async () => {
|
||||
const { CountUp } = await import('./animations');
|
||||
render(<CountUp end={1000} duration={3000} />);
|
||||
const element = screen.getByTestId('motion-span');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with prefix and suffix', async () => {
|
||||
const { CountUp } = await import('./animations');
|
||||
render(<CountUp end={100} prefix="$" suffix="%" />);
|
||||
expect(screen.getByText(/\$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/%/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RippleButton', () => {
|
||||
it('should create ripple on click', async () => {
|
||||
const { RippleButton } = await import('./animations');
|
||||
render(<RippleButton>Click Me</RippleButton>);
|
||||
|
||||
const button = screen.getByTestId('motion-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
// Ripple creates a motion.span
|
||||
expect(screen.getByTestId('motion-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle multiple clicks', async () => {
|
||||
const { RippleButton } = await import('./animations');
|
||||
const handleClick = jest.fn();
|
||||
render(<RippleButton onClick={handleClick}>Click Me</RippleButton>);
|
||||
|
||||
const button = screen.getByTestId('motion-button');
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should apply custom rippleColor', async () => {
|
||||
const { RippleButton } = await import('./animations');
|
||||
render(<RippleButton rippleColor="rgba(0, 0, 255, 0.5)">Click</RippleButton>);
|
||||
|
||||
const button = screen.getByTestId('motion-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText('Click')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RotatingBorder', () => {
|
||||
it('should render with custom borderWidth', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom duration', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
|
||||
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
|
||||
const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}');
|
||||
expect(animate).toEqual({ rotate: 360 });
|
||||
});
|
||||
|
||||
it('should have infinite repeat transition', async () => {
|
||||
const { RotatingBorder } = await import('./animations');
|
||||
render(<RotatingBorder duration={4}>Test</RotatingBorder>);
|
||||
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
|
||||
const transitionAttr = motionDiv.getAttribute('data-transition') || '';
|
||||
// JSON.stringify(Infinity) returns null, so check string representation
|
||||
expect(transitionAttr).toContain('"repeat"');
|
||||
expect(transitionAttr).toContain('"duration"');
|
||||
expect(transitionAttr).toContain('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SplitText', () => {
|
||||
it('should render with custom delay', async () => {
|
||||
const { SplitText } = await import('./animations');
|
||||
render(<SplitText text="ABC" delay={0.5} />);
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WaveText', () => {
|
||||
it('should render with custom delay', async () => {
|
||||
const { WaveText } = await import('./animations');
|
||||
render(<WaveText text="ABC" delay={0.5} />);
|
||||
const spans = screen.getAllByTestId('motion-span');
|
||||
expect(spans.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Typewriter', () => {
|
||||
it('should render with custom speed', async () => {
|
||||
const { Typewriter } = await import('./animations');
|
||||
render(<Typewriter text="Hello" speed={100} />);
|
||||
const cursor = screen.getByText('|');
|
||||
expect(cursor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GradientText', () => {
|
||||
it('should render with custom colors', async () => {
|
||||
const { GradientText } = await import('./animations');
|
||||
render(<GradientText colors={['#ff0000', '#00ff00', '#0000ff']}>Test</GradientText>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with animation duration', async () => {
|
||||
const { GradientText } = await import('./animations');
|
||||
render(<GradientText duration={5}>Test</GradientText>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShimmerButton', () => {
|
||||
it('should render with animation', async () => {
|
||||
const { ShimmerButton } = await import('./animations');
|
||||
render(<ShimmerButton>Shimmer</ShimmerButton>);
|
||||
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
|
||||
const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}');
|
||||
expect(animate).toHaveProperty('backgroundPosition');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MagneticButton', () => {
|
||||
it('should render with custom strength', async () => {
|
||||
const { MagneticButton } = await import('./animations');
|
||||
render(<MagneticButton strength={0.5}>Test</MagneticButton>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle click events', async () => {
|
||||
const { MagneticButton } = await import('./animations');
|
||||
const handleClick = jest.fn();
|
||||
render(<MagneticButton onClick={handleClick}>Click</MagneticButton>);
|
||||
|
||||
const element = screen.getByText('Click');
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(handleClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlurReveal', () => {
|
||||
it('should render with custom delay', async () => {
|
||||
const { BlurReveal } = await import('./animations');
|
||||
render(<BlurReveal delay={0.5}>Test</BlurReveal>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlitchText', () => {
|
||||
it('should render text correctly', async () => {
|
||||
const { GlitchText } = await import('./animations');
|
||||
render(<GlitchText text="Test" />);
|
||||
const elements = screen.getAllByText('Test');
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FloatingElement', () => {
|
||||
it('should render with custom duration', async () => {
|
||||
const { FloatingElement } = await import('./animations');
|
||||
render(<FloatingElement duration={5}>Test</FloatingElement>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PulseElement', () => {
|
||||
it('should render with custom duration', async () => {
|
||||
const { PulseElement } = await import('./animations');
|
||||
render(<PulseElement duration={3}>Test</PulseElement>);
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Animation Hooks', () => {
|
||||
describe('useParallax', () => {
|
||||
it('should be defined', async () => {
|
||||
const { useParallax } = await import('./animations');
|
||||
expect(useParallax).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSmoothSpring', () => {
|
||||
it('should be defined', async () => {
|
||||
const { useSmoothSpring } = await import('./animations');
|
||||
expect(useSmoothSpring).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG Components', () => {
|
||||
describe('InkDropSVG', () => {
|
||||
it('should render SVG correctly', async () => {
|
||||
const { InkDropSVG } = await import('./animations');
|
||||
render(<InkDropSVG />);
|
||||
expect(screen.getByTestId('motion-svg')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('motion-circle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', async () => {
|
||||
const { InkDropSVG } = await import('./animations');
|
||||
render(<InkDropSVG className="ink-drop-class" />);
|
||||
const element = screen.getByTestId('motion-svg');
|
||||
expect(element).toHaveClass('ink-drop-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InkSplash', () => {
|
||||
it('should render SVG correctly', async () => {
|
||||
const { InkSplash } = await import('./animations');
|
||||
render(<InkSplash />);
|
||||
expect(screen.getByTestId('motion-svg')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('motion-path')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom color', async () => {
|
||||
const { InkSplash } = await import('./animations');
|
||||
render(<InkSplash color="#ff0000" />);
|
||||
const path = screen.getByTestId('motion-path');
|
||||
expect(path).toHaveAttribute('fill', '#ff0000');
|
||||
});
|
||||
|
||||
it('should apply custom size', async () => {
|
||||
const { InkSplash } = await import('./animations');
|
||||
render(<InkSplash size={200} />);
|
||||
const svg = screen.getByTestId('motion-svg');
|
||||
expect(svg).toHaveAttribute('width', '200');
|
||||
expect(svg).toHaveAttribute('height', '200');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,923 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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', fontFamily: 'inherit' }}
|
||||
>
|
||||
{text.split('').map((char, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={child}
|
||||
style={{ display: 'inline-block', fontFamily: 'inherit' }}
|
||||
>
|
||||
{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-brand 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(end);
|
||||
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;
|
||||
|
||||
setCount(0);
|
||||
|
||||
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