Extension Button
UI增强版按钮组件,基于shadcn/ui的Button组件封装,支持图标、加载状态、特效动画、复制功能等
使用示例
// 基础用法
<Button>按钮</Button>
// 带图标
<Button icon={ArrowRight} iconPlacement="right">
带图标按钮
</Button>
// 加载状态
<Button loading loadingIconPlacement="left">
加载中
</Button>
// 不同变体
<Button variant="destructive">危险按钮</Button>
<Button variant="outline">轮廓按钮</Button>
<Button variant="ghost">幽灵按钮</Button>
// 特效动画
<Button effect="shineHover">闪烁效果</Button>
<Button effect="gooeyRight">粘性效果</Button>
<Button effect="gradientSlideShow">渐变按钮</Button>
// 复制功能
<Button copyText="Hello World" icon={CopyIcon} iconPlacement="left">
复制文本
</Button>
// 自定义复制成功提示
<Button
copyText="Hello World"
icon={CopyIcon}
iconPlacement="left"
successText="已复制!"
timeout={5000}
>
复制文本
</Button>
// 带复制回调
<Button
copyText="Hello World"
icon={CopyIcon}
iconPlacement="left"
onCopy={(text) => console.log('Copied:', text)}
>
复制文本
</Button> 组件源码
"use client";
import * as React from "react";
import { Slot, Slottable } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Spinner } from "@components/ui/Spinner";
import { cn } from "@components/lib/utils";
import useCopy from "@hooks/useCopy";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 box-border",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 border border-accent rounded-md",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4",
},
effect: {
expandIcon: "group gap-0 relative",
ringHover:
"transition-all duration-300 hover:ring-2 hover:ring-primary/90 hover:ring-offset-2",
shine:
"relative overflow-hidden before:absolute before:inset-0 before:rounded-[inherit] before:bg-[linear-gradient(45deg,transparent_40%,rgba(255,255,255,0.3)_50%,transparent_60%)] before:bg-[length:250%_250%] before:bg-no-repeat before:bg-[position:200%_0] before:[animation:shine_3s_ease-out_infinite]",
shineHover:
"relative overflow-hidden before:absolute before:inset-0 before:rounded-[inherit] before:bg-[linear-gradient(45deg,transparent_40%,rgba(255,255,255,0.3)_50%,transparent_60%)] before:bg-[length:250%_250%] before:bg-no-repeat before:bg-[position:200%_0] before:transition-[background-position] before:duration-[1200ms] hover:before:bg-[position:-200%_0]",
gooeyRight:
"relative z-0 overflow-hidden transition-all duration-500 before:absolute before:inset-0 before:z-[-1] before:translate-x-[150%] before:translate-y-[150%] before:scale-[2.5] before:rounded-[100%] before:bg-white/10 before:transition-transform before:duration-1000 hover:before:translate-x-[0%] hover:before:translate-y-[0%]",
gooeyLeft:
"relative z-0 overflow-hidden transition-all duration-500 after:absolute after:inset-0 after:z-[-1] after:translate-x-[-150%] after:translate-y-[150%] after:scale-[2.5] after:rounded-[100%] after:bg-white/10 after:transition-transform after:duration-1000 hover:after:translate-x-[0%] hover:after:translate-y-[0%]",
underline:
"relative !no-underline after:absolute after:left-0 after:right-0 after:content-[''] after:bg-accent after:bottom-1 after:h-[1px] after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
hoverUnderline:
"relative !no-underline after:absolute after:left-0 after:right-0 after:content-[''] after:bg-accent after:bottom-1 after:h-[1px] after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
gradientSlideShow:
"bg-[size:400%] bg-[linear-gradient(-45deg,var(--gradient-lime),var(--gradient-ocean),var(--gradient-wine),var(--gradient-rust))] animate-gradient-flow",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
size: "default",
variant: "default",
},
},
);
type CopyProps = {
copyText?: string;
onCopy?: (text: string) => void;
successText?: React.ReactNode;
timeout?: number;
};
type CopyRefProps = {
copyText?: never;
onCopy?: never;
successText?: never;
timeout?: never;
};
export type CopyButtonProps = CopyProps | CopyRefProps;
type IconProps = {
icon?: React.ElementType;
iconPlacement: "left" | "right";
};
type IconRefProps = {
icon?: never;
iconPlacement?: undefined;
};
type LoadingProps = {
loading?: boolean;
loadingText?: string;
hideIconOnLoading?: boolean;
loadingIconPlacement?: "left" | "right";
};
export type ButtonIconProps = IconProps | IconRefProps;
export type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> &
ButtonIconProps &
CopyButtonProps &
LoadingProps & {
asChild?: boolean;
};
function Button({
className,
variant,
effect,
size,
icon: Icon,
iconPlacement,
loading,
loadingText = "Loading",
children,
hideIconOnLoading = false,
loadingIconPlacement = "right",
asChild = false,
copyText,
onCopy,
successText = "Copied!",
timeout = 3000,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
const timerRef = React.useRef<NodeJS.Timeout | null>(null);
const { copied, copyHandle, resetHandle } = useCopy();
React.useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
const handleCopy = async () => {
if (!copyText) {
return;
}
try {
await copyHandle(copyText);
onCopy?.(copyText);
// 设置定时器自动重置状态
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
resetHandle();
}, timeout);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (copyText) {
handleCopy();
}
props.onClick?.(e);
};
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className, effect }))}
disabled={loading}
onClick={handleClick}
{...props}
>
{/* loading 左侧图标 */}
{loading && loadingIconPlacement === "left" && <Spinner size="sm" />}
{/* icon */}
{Icon &&
iconPlacement === "left" &&
!(hideIconOnLoading && loading) &&
(effect === "expandIcon" ? (
<div className="w-0 translate-x-[-100%] pr-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pr-2 group-hover:opacity-100 overflow-hidden">
<Icon />
</div>
) : (
<Icon />
))}
<Slottable>
{loading ? loadingText : copied ? successText : children}
</Slottable>
{/* loading 右侧图标 */}
{loading && loadingIconPlacement === "right" && <Spinner size="sm" />}
{Icon &&
iconPlacement === "right" &&
!(hideIconOnLoading && loading) &&
(effect === "expandIcon" ? (
<div className="w-0 translate-x-[100%] pl-0 opacity-0 transition-all duration-200 group-hover:w-5 group-hover:translate-x-0 group-hover:pl-2 group-hover:opacity-100">
<Icon />
) : (
<Icon />
))}
</Comp>
);
}
export { Button, buttonVariants };