wui
组件

按钮 Button

显示一个按钮,或显示一个外观类似按钮的组件。

基础示例

Loading…

安装组件:

pnpm dlx wui@latest add @wui/button
安装依赖
pnpm add radix-ui class-variance-authority clsx tailwind-merge
添加 cn 工具函数,并将源码复制到 components/ui/button.tsx
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> 的全部属性,并额外提供:

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link"defaultVisual style of the button.
size"default" | "sm" | "lg" | "icon"defaultHeight and padding preset. Use `icon` for square icon-only buttons.
asChildbooleanfalseRender as the single child element via Radix Slot, e.g. to wrap an `<a>`.
motionbooleanfalseEnable 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.
ripplebooleanfalseEnable 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.
disabledboolean

事件

Button 会将 {...props} 展开到底层元素,因此支持所有原生按钮事件。

PropTypeDefaultDescription
onClick(event: React.MouseEvent<HTMLButtonElement>) => void通过鼠标、键盘或触摸激活按钮时触发。
onFocus / onBlur(event: React.FocusEvent<HTMLButtonElement>) => void焦点进入和离开事件。
type"button" | "submit" | "reset""button"按钮在表单中的行为;设为 submit 可提交表单。

asChild 会改变底层元素

启用 asChild 后,事件会绑定到你渲染的子元素(例如 <a>),而不是 <button>

扩展使用

变体

Loading…

尺寸

Loading…

搭配图标

Loading…

加载状态

Loading…

链接变体(动态下划线)

link 变体会在悬停时让下划线滑入,并同步平滑改变文字颜色,无需额外配置。下划线紧贴文字标签 而不是按钮的内边距边界,因此在任何 size 下都能保持合适的视觉效果。

Loading…

通过 asChild 渲染链接

使用 asChild 渲染真实链接时,按钮会将元素控制权交给子元素,包裹标签的下划线动画无法应用, 因此 link 变体会回退为普通的 hover:underline。如有需要,请直接在 <a> 上添加动画样式。

作为链接(asChild

asChild 与链接元素组合,可渲染一个外观像按钮、语义仍为 <a> 的元素:

Loading…

动效(可选)

设置 motion 属性可启用轻微的弹性按压与悬停微交互。该功能依赖 motion,并会在用户偏好减少动态效果时自动禁用。

Loading…

涟漪(可选)

设置 ripple 属性可启用从指针位置向外扩散的 Material 风格点击涟漪。它可以与 motion 组合使用;与其他 wui 动画一样,启用 prefers-reduced-motion 时不会播放。使用 asChild 时会忽略 ripple

Loading…