组件
按钮 Button
用于触发即时操作、提交表单或进行页面跳转的基础交互组件,支持多种语义变体、尺寸密度以及可选的微动效与涟漪反馈。
基础用法
最简单的按钮形态。点击即可触发绑定的操作事件:
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"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 尺寸。 |
| asChild | boolean | false | 是否将样式与属性合并到唯一子元素(基于 Radix Slot,常用于包裹 <a> 或路由 Link 组件)。 |
| motion | boolean | false | 是否开启轻微的物理弹簧悬停与按压缩放微动效。系统开启减少动态效果时自动降级。 |
| ripple | boolean | false | 是否开启从光标点击坐标向外扩散的 Material 风格涟漪动效。支持与 motion 组合使用。 |
| loading | boolean | false | 加载状态。开启后加载指示器会从文案左侧平滑展开(icon 尺寸下与图标交叉淡入替换),同时禁用按钮并设置 aria-busy。使用 asChild 时仅同步状态属性,不注入指示器。 |
| disabled | boolean | false | 是否禁用按钮,处于禁用状态时无法被点击或通过键盘获得焦点。 |
| type | "button" | "submit" | "reset" | "submit" | 原生表单按钮类型,未声明时遵循浏览器默认值 submit。表单内的非提交按钮请显式声明为 button。 |
| className | string | — | 应用于按钮容器的额外 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="操作描述",确保屏幕阅读器用户能明确按钮用途。