Files
novalon-website/src/components/ui/ripple-button.tsx
T
2026-02-23 10:38:29 +08:00

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 };