wui
组件

对话框 Dialog

基于 Radix UI 与 Motion 构建的模态浮层窗口,支持焦点锁定、平滑弹性展开动效以及原地布局变换。

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

基础用法

点击触发按钮即可在视口中央唤起模态对话框,支持通过标题、描述、操作区与关闭按钮组织内容:

Loading…

安装与引入

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

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

import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { XIcon } from "lucide-react"
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "motion/react"

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

// The button and the panel each own a text-less background surface that shares
// this spring. Motion morphs the `layoutId` between them — like the tabs
// indicator — so the outline glides from button to panel and back.
const SURFACE_SPRING = {
  type: "spring",
  stiffness: 420,
  damping: 34,
  mass: 0.7,
} as const

// Shared between the parts so the panel can (a) drive its own enter/exit with
// AnimatePresence and (b) emerge from — and return to — the trigger's position.
type DialogContextValue = {
  open: boolean
  triggerRef: React.RefObject<HTMLElement | null>
  variant: "modal" | "inline"
  layoutId: string
  /** Radix modality; a non-modal panel renders without the dimmed overlay. */
  modal: boolean
}

const DialogContext = React.createContext<DialogContextValue | null>(null)

function useDialogContext() {
  const ctx = React.useContext(DialogContext)
  if (!ctx) throw new Error("Dialog parts must be used inside <Dialog>.")
  return ctx
}

const MotionOverlay = motion.create(DialogPrimitive.Overlay)
const MotionContent = motion.create(DialogPrimitive.Content)

export interface DialogProps
  extends React.ComponentProps<typeof DialogPrimitive.Root> {
  /** Render as a centred modal or morph in place inside the document flow. @default "modal" */
  variant?: "modal" | "inline"
}

function Dialog({
  open: openProp,
  defaultOpen,
  onOpenChange,
  modal = true,
  variant = "modal",
  children,
  ...props
}: DialogProps) {
  // Mirror the open state (without taking ownership away from a controlled
  // caller) so the content can run motion enter/exit via AnimatePresence.
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
    defaultOpen ?? false
  )
  const open = openProp ?? uncontrolledOpen
  const triggerRef = React.useRef<HTMLElement | null>(null)
  const layoutId = React.useId()

  const handleOpenChange = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const root = (
    <DialogPrimitive.Root
      data-slot="dialog"
      open={open}
      modal={variant === "inline" ? false : modal}
      onOpenChange={handleOpenChange}
      {...props}
    >
      {children}
    </DialogPrimitive.Root>
  )

  return (
    <DialogContext.Provider
      value={{ open, triggerRef, variant, layoutId, modal: variant === "modal" && modal }}
    >
      <LayoutGroup id={layoutId}>{root}</LayoutGroup>
    </DialogContext.Provider>
  )
}

function DialogTrigger(
  props: React.ComponentProps<typeof DialogPrimitive.Trigger>
) {
  const { open, triggerRef, variant, layoutId } = useDialogContext()
  const reduceMotion = useReducedMotion()

  if (variant === "inline") {
    // While the panel is open the button is gone; its surface has already
    // handed its `layoutId` off to the panel. Conditional render (no
    // AnimatePresence/popLayout) keeps the hand-off in a single commit so the
    // shared-layout morph fires — the same pattern as the tabs indicator.
    if (open) return null
    return (
      <div className="relative inline-block [&_[data-slot=dialog-trigger]]:border-transparent [&_[data-slot=dialog-trigger]]:bg-transparent [&_[data-slot=dialog-trigger]]:shadow-none">
        <motion.div
          aria-hidden
          data-slot="dialog-layout-surface"
          layoutId={`${layoutId}-surface`}
          className="pointer-events-none absolute inset-0 rounded-md border bg-background shadow-xs"
          transition={reduceMotion ? { duration: 0 } : SURFACE_SPRING}
        />
        <motion.div
          className="relative z-10"
          initial={reduceMotion ? false : { opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: 0.14, delay: 0.05, ease: "easeOut" }
          }
        >
          <DialogPrimitive.Trigger
            ref={triggerRef as React.Ref<HTMLButtonElement>}
            data-slot="dialog-trigger"
            {...props}
          />
        </motion.div>
      </div>
    )
  }

  return (
    <DialogPrimitive.Trigger
      ref={triggerRef as React.Ref<HTMLButtonElement>}
      data-slot="dialog-trigger"
      {...props}
    />
  )
}

function DialogPortal(
  props: React.ComponentProps<typeof DialogPrimitive.Portal>
) {
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}

function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}

function DialogOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
  return (
    <DialogPrimitive.Overlay
      data-slot="dialog-overlay"
      className={cn("fixed inset-0 z-50 bg-overlay", className)}
      {...props}
    />
  )
}

function DialogContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
  const { open, triggerRef, variant, layoutId, modal } = useDialogContext()
  const reduceMotion = useReducedMotion()

  // The panel rests at the viewport centre (flex container below). Express the
  // trigger's centre as an offset from there, so the panel can grow out of the
  // button on open and shrink back into it on close.
  const origin = React.useMemo(() => {
    if (!open || reduceMotion || variant === "inline") return { x: 0, y: 0 }
    const el = triggerRef.current
    if (!el || typeof window === "undefined") return { x: 0, y: 0 }
    const rect = el.getBoundingClientRect()
    return {
      x: rect.left + rect.width / 2 - window.innerWidth / 2,
      y: rect.top + rect.height / 2 - window.innerHeight / 2,
    }
    // Recompute each time the panel opens.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open, reduceMotion])

  if (variant === "inline") {
    // Mirror of DialogTrigger: render only while open, so the panel's surface
    // adopts the `layoutId` in the same commit the button's surface releases it
    // and Motion morphs the outline between the two boxes. `forceMount` stops
    // Radix from re-animating the mount/unmount on top of the layout morph.
    if (!open) return null
    return (
      <MotionContent
        forceMount
        data-slot="dialog-content"
        className={cn(
          "relative w-[min(32rem,calc(100vw-2rem))] overflow-hidden rounded-lg outline-none",
          className
        )}
        initial={false}
        {...(props as unknown as React.ComponentProps<typeof MotionContent>)}
      >
        <motion.div
          aria-hidden
          data-slot="dialog-layout-surface"
          layoutId={`${layoutId}-surface`}
          className="pointer-events-none absolute inset-0 rounded-lg border bg-background shadow-sm"
          transition={reduceMotion ? { duration: 0 } : SURFACE_SPRING}
        />
        <motion.div
          data-slot="dialog-layout-content"
          className="relative z-10 grid gap-4 p-6"
          initial={reduceMotion ? false : { opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: 0.18, delay: 0.05, ease: "easeOut" }
          }
        >
          {children}
          <DialogContentClose />
        </motion.div>
      </MotionContent>
    )
  }

  const hidden = reduceMotion
    ? { opacity: 0 }
    : { opacity: 0, scale: 0.78, x: origin.x, y: origin.y }
  const shown = reduceMotion
    ? { opacity: 1 }
    : { opacity: 1, scale: 1, x: 0, y: 0 }

  const panel = (
    <MotionContent
      data-slot="dialog-content"
      forceMount
      className={cn(
        "pointer-events-auto relative grid w-full max-w-[calc(100%-2rem)] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg",
        className
      )}
      initial={hidden}
      animate={shown}
      // Leave faster than we arrive: a short ease-in back towards the trigger
      // reads as "put away" instead of a second, slower spring.
      exit={{
        ...hidden,
        transition: reduceMotion
          ? { duration: 0.12 }
          : { duration: 0.2, ease: [0.4, 0, 1, 1] },
      }}
      transition={
        reduceMotion
          ? { duration: 0.15 }
          : {
              type: "spring",
              stiffness: 440,
              damping: 34,
              mass: 0.65,
              opacity: { duration: 0.16, ease: "easeOut" },
            }
      }
      {...(props as unknown as React.ComponentProps<typeof MotionContent>)}
    >
      {children}
      <DialogContentClose />
    </MotionContent>
  )

  return (
    <AnimatePresence>
      {open ? (
        <DialogPortal key="dialog" forceMount>
          {modal ? (
            /*
              Overlay must own the content in the React tree. Radix locks page
              scrolling from the overlay; nesting lets portalled controls inside
              the dialog (popover, select, date picker) remain scrollable.
              Centre with flex (not a translate on the panel) so the motion
              transform is free to drive the button→dialog morph.
            */
            <MotionOverlay
              data-slot="dialog-overlay"
              forceMount
              className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-overlay p-4"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0, transition: { duration: 0.18, ease: "easeIn" } }}
              transition={{ duration: 0.16, ease: "easeOut" }}
            >
              {panel}
            </MotionOverlay>
          ) : (
            // Radix renders no overlay for non-modal dialogs; keep the same
            // centring without blocking the page behind.
            <div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
              {panel}
            </div>
          )}
        </DialogPortal>
      ) : null}
    </AnimatePresence>
  )
}

function DialogContentClose() {
  return (
    <DialogPrimitive.Close
      data-slot="dialog-close-button"
      className="absolute right-4 top-4 flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
    >
      <XIcon />
      <span className="sr-only">关闭</span>
    </DialogPrimitive.Close>
  )
}

function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="dialog-header"
      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
      {...props}
    />
  )
}

function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="dialog-footer"
      className={cn(
        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
        className
      )}
      {...props}
    />
  )
}

function DialogTitle({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  return (
    <DialogPrimitive.Title
      data-slot="dialog-title"
      className={cn("text-lg font-semibold leading-none", className)}
      {...props}
    />
  )
}

function DialogDescription({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
  return (
    <DialogPrimitive.Description
      data-slot="dialog-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
}

属性 Props

Dialog (根组件)

Dialog 支持受控与非受控模式,以及模态与原地布局形态切换:

属性类型默认值说明
openboolean—受控模式下的打开状态,需配合 onOpenChange 使用。
defaultOpenbooleanfalse非受控模式下的初始打开状态。
onOpenChange(open: boolean) => void—对话框打开或关闭状态变化时的回调函数。
modalbooleantrue是否以模态方式呈现。开启后会渲染背景遮罩并阻止与外部页面交互;设为 false 时弹窗仍居中显示,但不渲染遮罩、不锁定滚动与焦点。
variant"modal" | "inline""modal"渲染形态。"modal" 为视口居中模态弹窗;"inline" 为基于触发元素的原地非模态布局变换。

DialogContent

DialogContent 为对话框浮层主体面板,继承 Radix Dialog Content 的全部 HTML 属性:

属性类型默认值说明
classNamestring—应用于对话框浮层面板的自定义类名(如自定义宽度 `sm:max-w-[600px]`)。
asChildbooleanfalse是否将属性与行为合并到唯一的子元素上渲染。

DialogTrigger / DialogClose

属性类型默认值说明
asChildbooleanfalse是否将触发/关闭行为委派给自定义子元素(通常配合 Button 使用)。

DialogHeader / DialogFooter / DialogTitle / DialogDescription

属性类型默认值说明
classNamestring—应用于相应结构容器的额外样式类名。
childrenReact.ReactNode—容器内部渲染的文本或节点内容。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—当用户通过点击触发器、点击遮罩、按下 Esc 键或点击关闭按钮导致开启状态改变时触发。
onPointerDownOutside(event: PointerDownOutsideEvent) => void—在模态框外部按下指针时触发。可通过 event.preventDefault() 阻止点击遮罩关闭。
onEscapeKeyDown(event: KeyboardEvent) => void—按下 Esc 键时触发。可通过 event.preventDefault() 阻止按键关闭行为。
onOpenAutoFocus(event: Event) => void—对话框打开并完成入场时焦点移入前触发,可自定义首个聚焦元素。
onCloseAutoFocus(event: Event) => void—对话框关闭且退场动效完成时触发,默认将焦点归还给触发元素。

使用场景与设计规范

Dialog 用于中断用户当前流程,引导用户聚焦于高优先级的操作任务或重要信息决策:

  • 组件选型对比:
    • Dialog vs Drawer:对于简短、强聚焦、破坏性或即刻决策的场景(如密码确认、新建配置),优先使用居中的 Dialog;对于包含多字段长表单、主从关联详情、复杂过滤筛选器的场景,优先使用侧边滑出的 Drawer。
    • Dialog vs ConfirmDialog:如果仅需简单的“确定/取消”二元确认,推荐使用轻量开箱即用的 ConfirmDialog;如果包含自定义表单控件、多步骤或长说明文本,使用灵活组合的 Dialog。
    • Dialog vs Popover:Popover 是依附于触发器的轻量非模态气泡卡片,不锁定焦点;Dialog 会阻断页面交互并锁定焦点。
  • 信息层级与语义规范:
    • 每个 DialogContent 必须包含 DialogTitle,便于屏幕阅读器朗读。若设计上无需显式标题,需添加 sr-only 隐藏文本或通过 aria-label 声明。
    • 底部操作栏(DialogFooter)应遵循视觉主次规范:主要操作(Primary Button)置于最右侧,取消操作(Outline/Ghost)紧随其左。
  • 动态原点展开(Origin Morphing):
    • 组件内置智能原点计算能力:打开时,面板会自动从触发按钮的几何中心以弹性缓动膨胀至屏幕中央;关闭时则精准缩回原按钮,维持空间连续性。

场景示例

表单交互与受控状态

在对话框中嵌入复杂表单,通过受控的 open 状态结合异步请求与加载反馈,提供安全顺畅的提交体验:

Loading…

原地布局变换(非模态 variant="inline")

设置 variant="inline" 时,Dialog 会在原文档流位置展开,Trigger 与 Content 共享同一个背景表面并执行平滑的形态变换,适合轻量级行内设置与详情展开:

Loading…

非模态布局注意事项

variant="inline" 不会渲染全屏遮罩,也不会锁定全局焦点,适合嵌入在卡片或侧边栏内的局部编辑场景。如果需要阻断用户操作,请使用默认的 variant="modal"。

自定义尺寸与大内容展示

通过为 DialogContent 传入 Tailwind 宽度类(例如 sm:max-w-[640px]),可扩展对话框宽度以展示代码片段、数据结构或多栏信息:

Loading…

破坏性操作二次确认

对于删除仓库、清空数据等不可逆危险操作,可在对话框中要求用户输入确认关键字,配合警示色按钮防止误触:

Loading…

无障碍与交互 Accessibility

  • WAI-ARIA 规范:
    • 浮层容器自动赋予 role="dialog" 或 role="alertdialog"。
    • 自动绑定 aria-labelledby(指向 DialogTitle)与 aria-describedby(指向 DialogDescription)。
  • 焦点捕获与恢复(Focus Trap & Return):
    • 打开弹窗时,焦点自动锁定在弹窗内部,防止用户通过 Tab 意外聚焦到遮罩层下方的背景元素。
    • 关闭弹窗后,焦点会自动无缝归还至触发此弹窗的原始按钮,确保键盘流体验不中断。
  • 键盘导航:
    • Esc:快速退出并关闭对话框。
    • Tab / Shift + Tab:在弹窗内部的所有可聚焦元素(输入框、操作按钮、右上角关闭按钮)之间顺次循环。
  • 动效降级:
    • 监听系统的 prefers-reduced-motion 设置。开启减弱动态效果后,弹性缩放生长动效将优雅降级为即时的不透明度淡入淡出。