feat: add multiple magic ui button components including AnimatedSubscribeButton, InteractiveHoverButton, PulsatingButton, RippleButton, ShimmerButton, and ShinyButton to Magicui

This commit is contained in:
javayhu 2025-04-26 10:32:09 +08:00
parent 3fff508728
commit 35005ea725
8 changed files with 466 additions and 16 deletions

View File

@ -0,0 +1,98 @@
"use client";
import { cn } from "@/lib/utils";
import { HTMLMotionProps } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import React, { useState } from "react";
interface AnimatedSubscribeButtonProps
extends Omit<HTMLMotionProps<"button">, "ref"> {
subscribeStatus?: boolean;
children: React.ReactNode;
className?: string;
}
export const AnimatedSubscribeButton = React.forwardRef<
HTMLButtonElement,
AnimatedSubscribeButtonProps
>(
(
{ subscribeStatus = false, onClick, className, children, ...props },
ref,
) => {
const [isSubscribed, setIsSubscribed] = useState<boolean>(subscribeStatus);
if (
React.Children.count(children) !== 2 ||
!React.Children.toArray(children).every(
(child) => React.isValidElement(child) && child.type === "span",
)
) {
throw new Error(
"AnimatedSubscribeButton expects two children, both of which must be <span> elements.",
);
}
const childrenArray = React.Children.toArray(children);
const initialChild = childrenArray[0];
const changeChild = childrenArray[1];
return (
<AnimatePresence mode="wait">
{isSubscribed ? (
<motion.button
ref={ref}
className={cn(
"relative flex h-10 w-fit items-center justify-center overflow-hidden rounded-lg bg-primary px-6 text-primary-foreground",
className,
)}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
setIsSubscribed(false);
onClick?.(e);
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
{...props}
>
<motion.span
key="action"
className="relative flex h-full w-full items-center justify-center font-semibold"
initial={{ y: -50 }}
animate={{ y: 0 }}
>
{changeChild} {/* Use children for subscribed state */}
</motion.span>
</motion.button>
) : (
<motion.button
ref={ref}
className={cn(
"relative flex h-10 w-fit cursor-pointer items-center justify-center rounded-lg border-none bg-primary px-6 text-primary-foreground",
className,
)}
onClick={(e) => {
setIsSubscribed(true);
onClick?.(e);
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
{...props}
>
<motion.span
key="reaction"
className="relative flex items-center justify-center font-semibold"
initial={{ x: 0 }}
exit={{ x: 50, transition: { duration: 0.1 } }}
>
{initialChild} {/* Use children for unsubscribed state */}
</motion.span>
</motion.button>
)}
</AnimatePresence>
);
},
);
AnimatedSubscribeButton.displayName = "AnimatedSubscribeButton";

View File

@ -0,0 +1,35 @@
import React from "react";
import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface InteractiveHoverButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
export const InteractiveHoverButton = React.forwardRef<
HTMLButtonElement,
InteractiveHoverButtonProps
>(({ children, className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"group relative w-auto cursor-pointer overflow-hidden rounded-full border bg-background p-2 px-6 text-center font-semibold",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary transition-all duration-300 group-hover:scale-[100.8]"></div>
<span className="inline-block transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
{children}
</span>
</div>
<div className="absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 text-primary-foreground opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
<span>{children}</span>
<ArrowRight />
</div>
</button>
);
});
InteractiveHoverButton.displayName = "InteractiveHoverButton";

View File

@ -0,0 +1,46 @@
import React from "react";
import { cn } from "@/lib/utils";
interface PulsatingButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
pulseColor?: string;
duration?: string;
}
export const PulsatingButton = React.forwardRef<
HTMLButtonElement,
PulsatingButtonProps
>(
(
{
className,
children,
pulseColor = "#808080",
duration = "1.5s",
...props
},
ref,
) => {
return (
<button
ref={ref}
className={cn(
"relative flex cursor-pointer items-center justify-center rounded-lg bg-primary px-4 py-2 text-center text-primary-foreground",
className,
)}
style={
{
"--pulse-color": pulseColor,
"--duration": duration,
} as React.CSSProperties
}
{...props}
>
<div className="relative z-10">{children}</div>
<div className="absolute left-1/2 top-1/2 size-full -translate-x-1/2 -translate-y-1/2 animate-pulse rounded-lg bg-inherit" />
</button>
);
},
);
PulsatingButton.displayName = "PulsatingButton";

View File

@ -1,5 +1,5 @@
import React from "react";
import { cn } from "@/lib/utils";
import React from "react";
interface RainbowButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
@ -12,13 +12,13 @@ export const RainbowButton = React.forwardRef<
<button
ref={ref}
className={cn(
"group relative inline-flex h-11 animate-rainbow cursor-pointer items-center justify-center rounded-xl border-0 bg-[length:200%] px-8 py-2 font-medium text-primary-foreground transition-colors [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.08*1rem)_solid_transparent] focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"group relative inline-flex h-11 animate-rainbow cursor-pointer items-center justify-center rounded-xl border-0 bg-[length:200%] px-8 py-2 font-medium text-primary-foreground transition-colors [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.08*1rem)_solid_transparent] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
// before styles
"before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,hsl(var(--color-1)),hsl(var(--color-5)),hsl(var(--color-3)),hsl(var(--color-4)),hsl(var(--color-2)))] before:[filter:blur(calc(0.8*1rem))]",
"before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(calc(0.8*1rem))]",
// light mode colors
"bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,hsl(var(--color-1)),hsl(var(--color-5)),hsl(var(--color-3)),hsl(var(--color-4)),hsl(var(--color-2)))]",
"bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
// dark mode colors
"dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,hsl(var(--color-1)),hsl(var(--color-5)),hsl(var(--color-3)),hsl(var(--color-4)),hsl(var(--color-2)))]",
"dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
className,
)}
{...props}

View File

@ -0,0 +1,91 @@
"use client";
import { cn } from "@/lib/utils";
import React, { MouseEvent, useEffect, useState } from "react";
interface RippleButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
rippleColor?: string;
duration?: string;
}
export const RippleButton = React.forwardRef<
HTMLButtonElement,
RippleButtonProps
>(
(
{
className,
children,
rippleColor = "#ffffff",
duration = "600ms",
onClick,
...props
},
ref,
) => {
const [buttonRipples, setButtonRipples] = useState<
Array<{ x: number; y: number; size: number; key: number }>
>([]);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
createRipple(event);
onClick?.(event);
};
const createRipple = (event: MouseEvent<HTMLButtonElement>) => {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
const newRipple = { x, y, size, key: Date.now() };
setButtonRipples((prevRipples) => [...prevRipples, newRipple]);
};
useEffect(() => {
if (buttonRipples.length > 0) {
const lastRipple = buttonRipples[buttonRipples.length - 1];
const timeout = setTimeout(() => {
setButtonRipples((prevRipples) =>
prevRipples.filter((ripple) => ripple.key !== lastRipple.key),
);
}, parseInt(duration));
return () => clearTimeout(timeout);
}
}, [buttonRipples, duration]);
return (
<button
className={cn(
"relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 bg-background px-4 py-2 text-center text-primary",
className,
)}
onClick={handleClick}
ref={ref}
{...props}
>
<div className="relative z-10">{children}</div>
<span className="pointer-events-none absolute inset-0">
{buttonRipples.map((ripple) => (
<span
className="absolute animate-rippling rounded-full bg-background opacity-30"
key={ripple.key}
style={{
width: `${ripple.size}px`,
height: `${ripple.size}px`,
top: `${ripple.y}px`,
left: `${ripple.x}px`,
backgroundColor: rippleColor,
transform: `scale(0)`,
}}
/>
))}
</span>
</button>
);
},
);
RippleButton.displayName = "RippleButton";

View File

@ -0,0 +1,96 @@
import React, { CSSProperties, ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
shimmerColor?: string;
shimmerSize?: string;
borderRadius?: string;
shimmerDuration?: string;
background?: string;
className?: string;
children?: React.ReactNode;
}
export const ShimmerButton = React.forwardRef<
HTMLButtonElement,
ShimmerButtonProps
>(
(
{
shimmerColor = "#ffffff",
shimmerSize = "0.05em",
shimmerDuration = "3s",
borderRadius = "100px",
background = "rgba(0, 0, 0, 1)",
className,
children,
...props
},
ref,
) => {
return (
<button
style={
{
"--spread": "90deg",
"--shimmer-color": shimmerColor,
"--radius": borderRadius,
"--speed": shimmerDuration,
"--cut": shimmerSize,
"--bg": background,
} as CSSProperties
}
className={cn(
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden whitespace-nowrap border border-white/10 px-6 py-3 text-white [background:var(--bg)] [border-radius:var(--radius)] dark:text-black",
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
className,
)}
ref={ref}
{...props}
>
{/* spark container */}
<div
className={cn(
"-z-30 blur-[2px]",
"absolute inset-0 overflow-visible [container-type:size]",
)}
>
{/* spark */}
<div className="absolute inset-0 h-[100cqh] animate-shimmer-slide [aspect-ratio:1] [border-radius:0] [mask:none]">
{/* spark before */}
<div className="absolute -inset-full w-auto rotate-0 animate-spin-around [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))] [translate:0_0]" />
</div>
</div>
{children}
{/* Highlight */}
<div
className={cn(
"insert-0 absolute size-full",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]",
// transition
"transform-gpu transition-all duration-300 ease-in-out",
// on hover
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
// on click
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]",
)}
/>
{/* backdrop */}
<div
className={cn(
"absolute -z-20 [background:var(--bg)] [border-radius:var(--radius)] [inset:var(--cut)]",
)}
/>
</button>
);
},
);
ShimmerButton.displayName = "ShimmerButton";

View File

@ -0,0 +1,72 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, MotionProps, type AnimationProps } from "motion/react";
import React from "react";
const animationProps = {
initial: { "--x": "100%", scale: 0.8 },
animate: { "--x": "-100%", scale: 1 },
whileTap: { scale: 0.95 },
transition: {
repeat: Infinity,
repeatType: "loop",
repeatDelay: 1,
type: "spring",
stiffness: 20,
damping: 15,
mass: 2,
scale: {
type: "spring",
stiffness: 200,
damping: 5,
mass: 0.5,
},
},
} as AnimationProps;
interface ShinyButtonProps
extends Omit<React.HTMLAttributes<HTMLElement>, keyof MotionProps>,
MotionProps {
children: React.ReactNode;
className?: string;
}
export const ShinyButton = React.forwardRef<
HTMLButtonElement,
ShinyButtonProps
>(({ children, className, ...props }, ref) => {
return (
<motion.button
ref={ref}
className={cn(
"relative rounded-lg px-6 py-2 font-medium backdrop-blur-xl transition-shadow duration-300 ease-in-out hover:shadow dark:bg-[radial-gradient(circle_at_50%_0%,var(--primary)/10%_0%,transparent_60%)] dark:hover:shadow-[0_0_20px_var(--primary)/10%]",
className,
)}
{...animationProps}
{...props}
>
<span
className="relative block size-full text-sm uppercase tracking-wide text-[rgb(0,0,0,65%)] dark:font-light dark:text-[rgb(255,255,255,90%)]"
style={{
maskImage:
"linear-gradient(-75deg,var(--primary) calc(var(--x) + 20%),transparent calc(var(--x) + 30%),var(--primary) calc(var(--x) + 100%))",
}}
>
{children}
</span>
<span
style={{
mask: "linear-gradient(rgb(0,0,0), rgb(0,0,0)) content-box exclude,linear-gradient(rgb(0,0,0), rgb(0,0,0))",
WebkitMask:
"linear-gradient(rgb(0,0,0), rgb(0,0,0)) content-box exclude,linear-gradient(rgb(0,0,0), rgb(0,0,0))",
backgroundImage:
"linear-gradient(-75deg,var(--primary)/10% calc(var(--x)+20%),var(--primary)/50% calc(var(--x)+25%),var(--primary)/10% calc(var(--x)+100%))",
}}
className="absolute inset-0 z-10 block rounded-[inherit] p-px"
></span>
</motion.button>
);
});
ShinyButton.displayName = "ShinyButton";

View File

@ -69,6 +69,9 @@
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-marquee: marquee var(--duration) infinite linear;
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
--animate-ripple: ripple var(--duration, 2s) ease calc(var(--i, 0) * .2s)
infinite;
--animate-pulse: pulse var(--duration) ease-out infinite;
@keyframes shiny-text {
0%,
@ -76,7 +79,6 @@
100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shiny-width)) 0;
@ -87,7 +89,6 @@
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
@ -97,7 +98,6 @@
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
@ -107,7 +107,6 @@
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
@ -131,13 +130,26 @@
}
}
--animate-ripple: ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite
;
@keyframes ripple {
0%, 100% {
transform: translate(-50%, -50%) scale(1);}
50% {
transform: translate(-50%, -50%) scale(0.9);}}}
0%,
100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(0.9);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--pulse-color);
}
50% {
box-shadow: 0 0 0 8px var(--pulse-color);
}
}
}
:root {
--radius: 0.5rem;
@ -290,4 +302,4 @@ body {
--primary: var(--color-amber-500);
--primary-foreground: var(--color-amber-50);
}
}
}