wui
组件

按钮 Button

用于触发即时操作、提交表单或进行页面跳转的基础交互组件,支持多种语义变体、尺寸密度以及可选的微动效与涟漪反馈。

第三方依赖 · radix-ui第三方依赖 · class-variance-authority第三方依赖 · motion第三方依赖 · lucide-react

基础用法

最简单的按钮形态。点击即可触发绑定的操作事件:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/button
安装基础依赖与动效库
pnpm add radix-ui motion class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/button.tsx
components/ui/button.tsx
"use client"

import * as React from "react"
import { Slot } from "radix-ui"
import { LoaderCircleIcon } from "lucide-react"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type HTMLMotionProps,
} from "motion/react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded-md text-sm font-medium outline-none transition-[color,background-color,border-color,box-shadow,opacity,scale] duration-200 ease-out active:scale-[0.97] motion-reduce:active:scale-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[loading]:disabled:opacity-80 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        // `link` keeps a transparent surface; the animated underline is drawn by
        // the wrapped label below (see `LinkLabel`). `group` lets that pseudo
        // element react to hover on the button itself.
        link: "group text-primary hover:text-primary/80 active:scale-100",
      },
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        icon: "size-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps extends React.ComponentProps<"button"> {
  /** Visual style of the button. */
  variant?:
    | "default"
    | "secondary"
    | "destructive"
    | "outline"
    | "ghost"
    | "link"
  /** Height and padding preset. Use `icon` for square icon-only buttons. */
  size?: "default" | "sm" | "lg" | "icon"
  /** Render as the single child element via Radix Slot, e.g. to wrap an `<a>`. */
  asChild?: boolean
  /**
   * Enable a subtle spring press/hover micro-interaction (powered by motion).
   * Composable with `ripple`. Ignored when `asChild` is set, and automatically
   * disabled when the user prefers reduced motion.
   */
  motion?: boolean
  /**
   * Enable a Material-style click ripple that radiates from the pointer.
   * Composable with `motion`. Ignored when `asChild` is set, and automatically
   * disabled when the user prefers reduced motion.
   */
  ripple?: boolean
  /**
   * Show an inline spinner and block interaction while an async action runs.
   * The spinner slides in before the label (or replaces the glyph for
   * `size="icon"`). Sets `aria-busy` and disables the button.
   */
  loading?: boolean
}

const spring = { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const

type Ripple = { key: number; x: number; y: number; size: number }

function Button({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  motion: enableMotion = false,
  ripple: enableRipple = false,
  loading: loadingProp,
  disabled,
  onClick,
  children,
  ...props
}: ButtonProps) {
  const reduceMotion = useReducedMotion()
  const loading = loadingProp === true
  const [ripples, setRipples] = React.useState<Ripple[]>([])
  const rippleKey = React.useRef(0)

  const isLink = variant === "link"
  const classes = cn(
    buttonVariants({ variant, size }),
    // `asChild` hands the element to the caller, so the wrapped-label underline
    // can't apply — fall back to a text-hugging (non-animated) underline.
    isLink && asChild && "underline-offset-4 hover:underline",
    className
  )
  const dataProps = {
    "data-slot": "button",
    "data-variant": variant,
    "data-size": size,
    "data-loading": loading || undefined,
    "aria-busy": loading || undefined,
    disabled: disabled || loading,
  }

  // asChild renders the caller's element verbatim — no effect layers injected.
  if (asChild) {
    return (
      <Slot.Root {...dataProps} className={classes} onClick={onClick} {...props}>
        {children}
      </Slot.Root>
    )
  }

  const useRipple = enableRipple && !reduceMotion
  const useMotion = enableMotion && !reduceMotion

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    if (useRipple) {
      const rect = event.currentTarget.getBoundingClientRect()
      const size = Math.max(rect.width, rect.height) * 2
      setRipples((prev) => [
        ...prev,
        {
          key: rippleKey.current++,
          x: event.clientX - rect.left - size / 2,
          y: event.clientY - rect.top - size / 2,
          size,
        },
      ])
    }
    onClick?.(event)
  }

  // Icon buttons only get the crossfade wrapper when they opt into `loading`,
  // so existing `[&>svg]` overrides keep matching the glyph directly.
  const isIcon = size === "icon" && loadingProp !== undefined
  const swap = reduceMotion ? { duration: 0 } : spring
  const gap = size === "sm" ? 6 : 8

  const label = isLink ? <LinkLabel>{children}</LinkLabel> : children

  const content = (
    <>
      {isIcon ? (
        <>
          <motion.span
            data-slot="button-label"
            className="inline-flex items-center justify-center"
            initial={false}
            animate={{ opacity: loading ? 0 : 1, scale: loading ? 0.6 : 1 }}
            transition={swap}
          >
            {children}
          </motion.span>
          <AnimatePresence initial={false}>
            {loading ? (
              <motion.span
                key="spinner"
                aria-hidden
                data-slot="button-spinner"
                className="absolute inset-0 flex items-center justify-center"
                initial={{ opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.6 }}
                transition={swap}
              >
                <LoaderCircleIcon className="animate-spin motion-reduce:animate-none" />
              </motion.span>
            ) : null}
          </AnimatePresence>
        </>
      ) : (
        <>
          <AnimatePresence initial={false}>
            {loading ? (
              <motion.span
                key="spinner"
                aria-hidden
                data-slot="button-spinner"
                className="inline-flex shrink-0 items-center overflow-hidden"
                initial={{ width: 0, opacity: 0, marginInlineEnd: -gap }}
                animate={{ width: 16, opacity: 1, marginInlineEnd: 0 }}
                exit={{ width: 0, opacity: 0, marginInlineEnd: -gap }}
                transition={swap}
              >
                <LoaderCircleIcon className="animate-spin motion-reduce:animate-none" />
              </motion.span>
            ) : null}
          </AnimatePresence>
          {label}
        </>
      )}
      {useRipple ? (
        <span
          aria-hidden
          data-slot="button-ripple"
          className="pointer-events-none absolute inset-0"
        >
          {ripples.map((r) => (
            <motion.span
              key={r.key}
              className="absolute rounded-full bg-current"
              style={{ left: r.x, top: r.y, width: r.size, height: r.size }}
              initial={{ scale: 0, opacity: 0.35 }}
              animate={{ scale: 1, opacity: 0 }}
              transition={{ duration: 0.6, ease: "easeOut" }}
              onAnimationComplete={() =>
                setRipples((prev) => prev.filter((p) => p.key !== r.key))
              }
            />
          ))}
        </span>
      ) : null}
    </>
  )

  if (useMotion) {
    return (
      <motion.button
        {...dataProps}
        className={classes}
        onClick={handleClick}
        whileTap={{ scale: 0.96 }}
        whileHover={{ scale: 1.02 }}
        transition={{ type: "spring", stiffness: 400, damping: 28, mass: 0.6 }}
        {...(props as unknown as HTMLMotionProps<"button">)}
      >
        {content}
      </motion.button>
    )
  }

  return (
    <button {...dataProps} className={classes} onClick={handleClick} {...props}>
      {content}
    </button>
  )
}

/**
 * Wraps a `link`-variant button's label so an animated underline can hug the
 * text (independent of the button's padding) and slide in on hover.
 */
function LinkLabel({ children }: { children: React.ReactNode }) {
  return (
    <span
      data-slot="button-label"
      className="relative inline-flex items-center gap-2 after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-right after:scale-x-0 after:bg-primary after:transition-transform after:duration-300 after:ease-out group-hover:after:origin-left group-hover:after:scale-x-100"
    >
      {children}
    </span>
  )
}

export { Button, buttonVariants }

属性 Props

Button 支持以下配置属性,并继承原生 <button> 的全部 HTML 属性:

属性类型默认值说明
variant"default" | "secondary" | "destructive" | "outline" | "ghost" | "link""default"按钮的视觉形态与语义层级。
size"default" | "sm" | "lg" | "icon""default"按钮的尺寸与内边距预设。纯图标按钮请使用 icon 尺寸。
asChildbooleanfalse是否将样式与属性合并到唯一子元素(基于 Radix Slot,常用于包裹 <a> 或路由 Link 组件)。
motionbooleanfalse是否开启轻微的物理弹簧悬停与按压缩放微动效。系统开启减少动态效果时自动降级。
ripplebooleanfalse是否开启从光标点击坐标向外扩散的 Material 风格涟漪动效。支持与 motion 组合使用。
loadingbooleanfalse加载状态。开启后加载指示器会从文案左侧平滑展开(icon 尺寸下与图标交叉淡入替换),同时禁用按钮并设置 aria-busy。使用 asChild 时仅同步状态属性,不注入指示器。
disabledbooleanfalse是否禁用按钮,处于禁用状态时无法被点击或通过键盘获得焦点。
type"button" | "submit" | "reset""submit"原生表单按钮类型,未声明时遵循浏览器默认值 submit。表单内的非提交按钮请显式声明为 button。
classNamestring—应用于按钮容器的额外 CSS 类名。

事件 Events

Button 支持原生按钮的所有事件回调函数,并通过属性解构直接透传至底层元素:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLButtonElement>) => void—用户通过鼠标点击、触摸屏幕或使用键盘(Enter / Space)激活按钮时触发。
onFocus(event: React.FocusEvent<HTMLButtonElement>) => void—按钮获得焦点时触发。
onBlur(event: React.FocusEvent<HTMLButtonElement>) => void—按钮失去焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) => void—按钮处于焦点状态下按下键盘按键时触发。
onPointerDown(event: React.PointerEvent<HTMLButtonElement>) => void—指针按下按钮表面时触发,可用于捕获涟漪起始位置或手势交互。

使用场景与设计规范

Button 用于表达明确的意图与行为,例如提交表单、打开模态框、导出数据或保存配置。

  • 视觉层级与操作主次:
    • 主要操作(Default):一个操作区域或页面内通常只保留一个主按钮(Primary),用于引导用户完成核心路径。
    • 次要操作(Secondary / Outline):用于“取消”、“重置”或辅助性配置,降低视觉争抢。
    • 危险操作(Destructive):删除、清空、注销等不可逆行为,必须使用高警示度的破坏性变体,并推荐配合确认弹窗二次校验。
    • 幽灵与链接(Ghost / Link):用于工具栏、卡片内部快捷操作或行内导航,避免过多实体框造成的界面杂乱。
  • 动词导向文案:按钮文案应精准描述点击后的结果,优先使用“创建工作区”、“保存草稿”、“导出报表”等动作性词汇,避免使用含糊的“确定”、“点击这里”。
  • 图标辅助与纯图标无障碍:
    • 图标与文案并存时,图标应放置在文字左侧(起强调或类型区分作用)或右侧(如箭头指示跳转)。
    • 当使用 size="icon" 仅渲染图标时,必须显式指定 aria-label 属性以保证屏幕阅读器能够识别操作意图。
  • 异步操作防护:发起网络请求期间,应切换为加载(Loading)状态并禁用交互,防止用户快速重复点击造成多次请求提交。

场景示例

视觉变体

组件内置 6 种语义化变体,满足不同场景下的视觉层级划分:

Loading…

尺寸密度

提供 sm(紧凑)、default(标准)、lg(突出)与 icon(等宽高纯图标)4 种尺寸预设:

Loading…

搭配图标

内置的 SVG 样式规则会自动规范图标大小与间距,保持与文字的完美基线对齐:

Loading…

加载状态(loading)

设置 loading 后,加载指示器会从文案左侧以弹簧曲线展开,按钮宽度随之平滑过渡;纯图标按钮则在原图标与指示器之间交叉淡入。加载期间按钮自动禁用并声明 aria-busy:

Loading…

异步请求与结果反馈

组合 loading 与结果状态,完整表达“发起 → 处理中 → 成功”的请求生命周期:

Loading…

动态下划线链接

variant="link" 在悬停时拥有平滑滑入的动态下划线,且下划线紧贴文字长度,不会受按钮外层内边距影响:

Loading…

语义化跳转(asChild)

通过 asChild 属性将按钮外观和状态委托给子元素(如 Next.js 的 <Link> 或原生的 <a>),保留原生超链接的 SEO 权重与中键新标签页打开能力:

Loading…
import Link from "next/link"
import { Button } from "@/registry/ui/button"

export function NavLink() {
  return (
    <Button asChild variant="outline">
      <Link href="/dashboard">进入控制台</Link>
    </Button>
  )
}

微交互与物理弹簧(motion)

开启 motion 属性后,按钮在悬停与按压时会产生基于物理弹簧的丝滑缩放动效:

Loading…

坐标扩散涟漪(ripple)

开启 ripple 属性后,点击时会根据光标所在坐标向外扩散波纹动效,增强触控与点击反馈:

Loading…

无障碍与交互 Accessibility

  • 语义化标签:标准模式下渲染语义化的 <button> 元素;使用 asChild 时将属性安全合并至子节点。
  • 键盘导航:
    • Tab:按 DOM 顺序聚焦至按钮,展示清晰的高对比度外发光焦点轮廓(Focus Ring)。
    • Enter / Space:触发按钮的点击操作。
  • 按压反馈:所有非 link 变体在按下时有轻微的缩放回弹(0.97),提供触感反馈。
  • 动效降级:内置的按压缩放、loading 指示器过渡、motion 弹簧缩放与 ripple 涟漪动画均自动监听系统 prefers-reduced-motion。当用户开启减少动态效果时,动效层自动关闭,确保舒适无干扰的交互。
  • 纯图标可访问性:纯图标按钮必须配置 aria-label="操作描述",确保屏幕阅读器用户能明确按钮用途。