按钮 Button
显示一个按钮,或显示一个外观类似按钮的组件。
基础示例
安装组件:
pnpm dlx wui@latest add @wui/buttonpnpm add radix-ui class-variance-authority clsx tailwind-mergecn 工具函数,并将源码复制到 components/ui/button.tsx"use client"
import * as React from "react"
import { Slot } from "radix-ui"
import { 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] duration-200 ease-out focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 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",
},
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
}
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,
onClick,
children,
...props
}: ButtonProps) {
const reduceMotion = useReducedMotion()
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,
}
// 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)
}
const content = (
<>
{isLink ? <LinkLabel>{children}</LinkLabel> : children}
{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 }
组件作用
Button 是触发操作的主要方式,例如提交表单、打开对话框或确认选择。任何需要“执行某个动作”
的场景都可以使用它。
- 当控件用于页面跳转而非执行操作时,使用
link变体,或通过asChild配合<a>。 - 删除等不可逆操作使用
variant="destructive"。 - 仅显示图标时使用
size="icon",并始终提供aria-label或视觉隐藏文字以保证无障碍体验。
交互演示
调整属性即可实时查看按钮变化。下方控件和属性表均根据组件的 TypeScript 类型与文档注释自动生成。
The rendered content.
Visual style of the button.
Height and padding preset. Use `icon` for square icon-only buttons.
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.
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.
组件属性
Button 继承原生 <button> 的全部属性,并额外提供:
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | default | Visual style of the button. |
| size | "default" | "sm" | "lg" | "icon" | default | Height and padding preset. Use `icon` for square icon-only buttons. |
| asChild | boolean | false | Render as the single child element via Radix Slot, e.g. to wrap an `<a>`. |
| motion | boolean | false | 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. |
| ripple | boolean | false | 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. |
| disabled | boolean | — |
事件
Button 会将 {...props} 展开到底层元素,因此支持所有原生按钮事件。
| Prop | Type | Default | Description |
|---|---|---|---|
| onClick | (event: React.MouseEvent<HTMLButtonElement>) => void | — | 通过鼠标、键盘或触摸激活按钮时触发。 |
| onFocus / onBlur | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 焦点进入和离开事件。 |
| type | "button" | "submit" | "reset" | "button" | 按钮在表单中的行为;设为 submit 可提交表单。 |
asChild 会改变底层元素
启用 asChild 后,事件会绑定到你渲染的子元素(例如 <a>),而不是 <button>。
扩展使用
变体
尺寸
搭配图标
加载状态
链接变体(动态下划线)
link 变体会在悬停时让下划线滑入,并同步平滑改变文字颜色,无需额外配置。下划线紧贴文字标签
而不是按钮的内边距边界,因此在任何 size 下都能保持合适的视觉效果。
通过 asChild 渲染链接
使用 asChild 渲染真实链接时,按钮会将元素控制权交给子元素,包裹标签的下划线动画无法应用,
因此 link 变体会回退为普通的 hover:underline。如有需要,请直接在 <a> 上添加动画样式。
作为链接(asChild)
将 asChild 与链接元素组合,可渲染一个外观像按钮、语义仍为 <a> 的元素:
动效(可选)
设置 motion 属性可启用轻微的弹性按压与悬停微交互。该功能依赖
motion,并会在用户偏好减少动态效果时自动禁用。
涟漪(可选)
设置 ripple 属性可启用从指针位置向外扩散的 Material 风格点击涟漪。它可以与 motion
组合使用;与其他 wui 动画一样,启用 prefers-reduced-motion 时不会播放。使用 asChild
时会忽略 ripple。