组件列表

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