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:
2026-09-03 19:28:18 +08:00
parent ceeb88aca3
commit 7e19661a99
4 changed files with 8 additions and 1783 deletions
+4 -9
View File
@@ -83,12 +83,9 @@ export function DetailHero({
borderColor: `${theme.accentColor}25`, borderColor: `${theme.accentColor}25`,
}} }}
> >
<motion.span <span>
animate={{ rotate: [0, 360] }}
transition={{ duration: 4, repeat: Infinity, ease: 'linear' as const }}
>
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
</motion.span> </span>
{badge || theme.badge} {badge || theme.badge}
</motion.div> </motion.div>
)} )}
@@ -192,13 +189,11 @@ export function DetailHero({
transition={{ delay: 1.5, duration: 0.3 }} transition={{ delay: 1.5, duration: 0.3 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2" className="absolute bottom-8 left-1/2 -translate-x-1/2"
> >
<motion.div <div
animate={{ y: [0, 8, 0] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' as const }}
className="text-text-muted cursor-pointer hover:text-text-secondary transition-colors" className="text-text-muted cursor-pointer hover:text-text-secondary transition-colors"
> >
<ChevronDown className="w-6 h-6" /> <ChevronDown className="w-6 h-6" />
</motion.div> </div>
</motion.div> </motion.div>
</div> </div>
</section> </section>
+4 -10
View File
@@ -5,8 +5,6 @@ import { useReducedMotion } from '@/hooks/use-reduced-motion';
// ===== 抽象几何装饰 - 用于各 section 背景 ===== // ===== 抽象几何装饰 - 用于各 section 背景 =====
export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circles' | 'lines' | 'grid' | 'waves' }) { export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circles' | 'lines' | 'grid' | 'waves' }) {
const shouldReduceMotion = useReducedMotion();
if (variant === 'circles') { if (variant === 'circles') {
return ( return (
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true"> <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="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="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" /> <circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
{!shouldReduceMotion && ( <circle
<motion.circle cx="100" cy="100" r="20"
cx="100" cy="100" r="20" fill="rgba(var(--color-brand-rgb, 196, 30, 58), 0.1)"
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" }}
/>
)}
</svg> </svg>
<svg className="absolute bottom-[15%] left-[8%] w-48 h-48 opacity-[0.03]" viewBox="0 0 200 200"> <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)" /> <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)" />
-841
View File
@@ -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');
});
});
});
-923
View File
@@ -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>
);
}