192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { motion, AnimatePresence, type HTMLMotionProps } from 'framer-motion';
|
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const rippleButtonVariants = cva(
|
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[#1C1C1C] focus-visible:ring-offset-2 focus-visible:ring-offset-white relative overflow-hidden',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default:
|
|
'bg-[#C41E3A] text-white hover:bg-[#A01830] hover:shadow-[0_8px_20px_rgba(196,30,58,0.35)]',
|
|
secondary:
|
|
'bg-[#1C1C1C] text-white hover:bg-[#0A0A0A] hover:shadow-[0_8px_20px_rgba(28,28,28,0.35)]',
|
|
destructive:
|
|
'bg-[#C41E3A] text-white hover:bg-[#A01830] focus-visible:ring-[#C41E3A]',
|
|
outline:
|
|
'border-2 border-[#1C1C1C] bg-transparent text-[#1C1C1C] hover:bg-[#F5F5F5] hover:shadow-[0_4px_12px_rgba(28,28,28,0.2)]',
|
|
ghost:
|
|
'text-[#3D3D3D] hover:bg-[#F5F5F5] hover:text-[#1C1C1C]',
|
|
link:
|
|
'text-[#1C1C1C] underline-offset-4 hover:underline hover:text-[#C41E3A]',
|
|
seal:
|
|
'bg-[#C41E3A] text-white font-semibold hover:bg-[#A01830] shadow-[0_6px_20px_rgba(196,30,58,0.3)]',
|
|
},
|
|
size: {
|
|
default: 'h-10 px-4 py-2',
|
|
sm: 'h-8 rounded-md px-3 text-xs',
|
|
lg: 'h-12 rounded-lg px-6 text-base',
|
|
icon: 'h-10 w-10',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'default',
|
|
size: 'default',
|
|
},
|
|
}
|
|
);
|
|
|
|
interface Ripple {
|
|
x: number;
|
|
y: number;
|
|
id: number;
|
|
}
|
|
|
|
export interface RippleButtonProps
|
|
extends VariantProps<typeof rippleButtonVariants> {
|
|
rippleColor?: string;
|
|
rippleDuration?: number;
|
|
children?: React.ReactNode;
|
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const RippleButton = React.forwardRef<HTMLButtonElement, RippleButtonProps>(
|
|
({ className, variant, size, rippleColor, rippleDuration = 800, onClick, children, disabled, ...props }, ref) => {
|
|
const [ripples, setRipples] = React.useState<Ripple[]>([]);
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (disabled) return;
|
|
|
|
const button = e.currentTarget;
|
|
const rect = button.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const id = Date.now();
|
|
|
|
setRipples((prev) => [...prev, { x, y, id }]);
|
|
|
|
setTimeout(() => {
|
|
setRipples((prev) => prev.filter((r) => r.id !== id));
|
|
}, rippleDuration);
|
|
|
|
onClick?.(e);
|
|
};
|
|
|
|
const getRippleColor = () => {
|
|
if (rippleColor) return rippleColor;
|
|
if (variant === 'outline' || variant === 'ghost' || variant === 'link') {
|
|
return 'rgba(196, 30, 58, 0.2)';
|
|
}
|
|
return 'rgba(255, 255, 255, 0.4)';
|
|
};
|
|
|
|
return (
|
|
<motion.button
|
|
ref={ref}
|
|
whileHover={disabled ? {} : { scale: 1.03, y: -3 }}
|
|
whileTap={disabled ? {} : { scale: 0.97 }}
|
|
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
|
className={cn(rippleButtonVariants({ variant, size, className }))}
|
|
onClick={handleClick}
|
|
disabled={disabled}
|
|
{...props}
|
|
>
|
|
{children}
|
|
<AnimatePresence>
|
|
{ripples.map((ripple) => (
|
|
<motion.span
|
|
key={ripple.id}
|
|
initial={{ scale: 0, opacity: 0.8 }}
|
|
animate={{ scale: 5, opacity: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: rippleDuration / 1000, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute w-6 h-6 rounded-full pointer-events-none"
|
|
style={{
|
|
left: ripple.x - 12,
|
|
top: ripple.y - 12,
|
|
backgroundColor: getRippleColor(),
|
|
}}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
</motion.button>
|
|
);
|
|
}
|
|
);
|
|
RippleButton.displayName = 'RippleButton';
|
|
|
|
export interface SealButtonProps extends VariantProps<typeof rippleButtonVariants> {
|
|
children?: React.ReactNode;
|
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const SealButton = React.forwardRef<HTMLButtonElement, SealButtonProps>(
|
|
({ className, variant = 'seal', size, onClick, children, disabled, ...props }, ref) => {
|
|
const [isPressed, setIsPressed] = React.useState(false);
|
|
const [showInk, setShowInk] = React.useState(false);
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (disabled) return;
|
|
setIsPressed(true);
|
|
setShowInk(true);
|
|
setTimeout(() => setIsPressed(false), 600);
|
|
setTimeout(() => setShowInk(false), 800);
|
|
onClick?.(e);
|
|
};
|
|
|
|
return (
|
|
<motion.button
|
|
ref={ref}
|
|
initial={{ scale: 1, rotate: 0 }}
|
|
whileHover={disabled ? {} : { scale: 1.06, y: -2 }}
|
|
whileTap={disabled ? {} : { scale: 0.94, rotate: -3 }}
|
|
animate={
|
|
isPressed
|
|
? {
|
|
scale: [1, 1.15, 0.95, 1.02, 1],
|
|
rotate: [0, -8, 8, -3, 0],
|
|
}
|
|
: {}
|
|
}
|
|
transition={{
|
|
type: 'spring',
|
|
stiffness: 400,
|
|
damping: 12,
|
|
}}
|
|
className={cn(
|
|
rippleButtonVariants({ variant, size, className }),
|
|
'seal-stamp'
|
|
)}
|
|
onClick={handleClick}
|
|
disabled={disabled}
|
|
{...props}
|
|
>
|
|
<AnimatePresence>
|
|
{showInk && (
|
|
<motion.div
|
|
initial={{ scale: 0, opacity: 0.6 }}
|
|
animate={{ scale: 3, opacity: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-white/20" />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
<span className="relative z-10 inline-flex items-center gap-1">{children}</span>
|
|
</motion.button>
|
|
);
|
|
}
|
|
);
|
|
SealButton.displayName = 'SealButton';
|
|
|
|
export { RippleButton, SealButton, rippleButtonVariants };
|