组件
对话框 Dialog
基于 Radix UI 与 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"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 支持受控与非受控模式,以及模态与原地布局形态切换:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| open | boolean | — | 受控模式下的打开状态,需配合 onOpenChange 使用。 |
| defaultOpen | boolean | false | 非受控模式下的初始打开状态。 |
| onOpenChange | (open: boolean) => void | — | 对话框打开或关闭状态变化时的回调函数。 |
| modal | boolean | true | 是否以模态方式呈现。开启后会渲染背景遮罩并阻止与外部页面交互;设为 false 时弹窗仍居中显示,但不渲染遮罩、不锁定滚动与焦点。 |
| variant | "modal" | "inline" | "modal" | 渲染形态。"modal" 为视口居中模态弹窗;"inline" 为基于触发元素的原地非模态布局变换。 |
DialogContent
DialogContent 为对话框浮层主体面板,继承 Radix Dialog Content 的全部 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于对话框浮层面板的自定义类名(如自定义宽度 `sm:max-w-[600px]`)。 |
| asChild | boolean | false | 是否将属性与行为合并到唯一的子元素上渲染。 |
DialogTrigger / DialogClose
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| asChild | boolean | false | 是否将触发/关闭行为委派给自定义子元素(通常配合 Button 使用)。 |
DialogHeader / DialogFooter / DialogTitle / DialogDescription
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于相应结构容器的额外样式类名。 |
| children | React.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会阻断页面交互并锁定焦点。
- Dialog vs Drawer:对于简短、强聚焦、破坏性或即刻决策的场景(如密码确认、新建配置),优先使用居中的
- 信息层级与语义规范:
- 每个
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设置。开启减弱动态效果后,弹性缩放生长动效将优雅降级为即时的不透明度淡入淡出。
- 监听系统的