组件
抽屉 Drawer
从屏幕边缘平滑滑出的焦点抽屉面板,用于承载查看详情、多步表单与辅助配置,同时保留主页面的上下文环境。
基础用法
点击触发按钮从屏幕边缘滑出抽屉面板,内置背景遮罩与弹性物理微动效:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/drawer安装基础依赖与动效库
pnpm add radix-ui motion lucide-react clsx tailwind-merge复制组件源码到
components/ui/drawer.tsx"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DrawerPrimitive } from "radix-ui"
import {
AnimatePresence,
motion,
useDragControls,
useReducedMotion,
} from "motion/react"
import { cn } from "@/lib/utils"
type DrawerContextValue = {
open: boolean
modal: boolean
setOpen: (open: boolean) => void
}
const DrawerContext = React.createContext<DrawerContextValue | null>(null)
const MotionContent = motion.create(DrawerPrimitive.Content)
function useDrawerContext() {
const context = React.useContext(DrawerContext)
if (!context) throw new Error("Drawer parts must be used inside <Drawer>.")
return context
}
export interface DrawerProps extends React.ComponentProps<
typeof DrawerPrimitive.Root
> {
/** Controlled visibility state. */
open?: boolean
/** Initial visibility in uncontrolled mode. @default false */
defaultOpen?: boolean
/** Called whenever the drawer requests a visibility change. */
onOpenChange?: (open: boolean) => void
/** Trap focus and disable interaction outside the panel. @default true */
modal?: boolean
}
function Drawer({
open: openProp,
defaultOpen,
onOpenChange,
modal = true,
children,
...props
}: DrawerProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false)
const open = openProp ?? internalOpen
const handleOpenChange = React.useCallback(
(next: boolean) => {
if (openProp === undefined) setInternalOpen(next)
onOpenChange?.(next)
},
[onOpenChange, openProp]
)
return (
<DrawerContext.Provider value={{ open, modal, setOpen: handleOpenChange }}>
<DrawerPrimitive.Root
data-slot="drawer"
open={open}
onOpenChange={handleOpenChange}
modal={modal}
{...props}
>
{children}
</DrawerPrimitive.Root>
</DrawerContext.Provider>
)
}
function DrawerTrigger(
props: React.ComponentProps<typeof DrawerPrimitive.Trigger>
) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerClose(
props: React.ComponentProps<typeof DrawerPrimitive.Close>
) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerPortal(
props: React.ComponentProps<typeof DrawerPrimitive.Portal>
) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
export interface DrawerContentProps extends React.ComponentProps<
typeof DrawerPrimitive.Content
> {
/** Edge from which the panel enters. @default "right" */
side?: "top" | "right" | "bottom" | "left"
/** Panel width or height preset. @default "default" */
size?: "sm" | "default" | "lg" | "full"
/** Hide the built-in close button. @default false */
hideClose?: boolean
/**
* Render a grab handle on the inner edge; dragging it towards the entry edge
* dismisses the panel once it passes a quarter of its size or is flung. @default false
*/
swipeToClose?: boolean
}
const PANEL_SPRING = {
type: "spring",
stiffness: 380,
damping: 40,
mass: 0.8,
} as const
/** A focus-managed edge panel with spring-based enter and exit motion. */
function DrawerContent({
className,
children,
side = "right",
size = "default",
hideClose = false,
swipeToClose = false,
ref,
...props
}: DrawerContentProps) {
const { open, modal, setOpen } = useDrawerContext()
const panelRef = React.useRef<HTMLDivElement | null>(null)
const setPanelRef = React.useCallback(
(node: HTMLDivElement | null) => {
panelRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref]
)
const reduceMotion = useReducedMotion()
const dragControls = useDragControls()
const horizontal = side === "left" || side === "right"
// +1 when the panel leaves towards the positive axis (right / bottom).
const exitSign = side === "right" || side === "bottom" ? 1 : -1
const hidden = reduceMotion
? { opacity: 0 }
: {
x: side === "left" ? "-100%" : side === "right" ? "100%" : 0,
y: side === "top" ? "-100%" : side === "bottom" ? "100%" : 0,
}
const placement = {
top: "inset-x-0 top-0 border-b",
right: "inset-y-0 right-0 border-l",
bottom: "inset-x-0 bottom-0 border-t",
left: "inset-y-0 left-0 border-r",
}[side]
const dimensions = horizontal
? {
sm: "w-[min(20rem,calc(100vw-1rem))]",
default: "w-[min(26rem,calc(100vw-1rem))]",
lg: "w-[min(38rem,calc(100vw-1rem))]",
full: "w-screen",
}[size]
: {
sm: "h-[min(16rem,calc(100vh-1rem))]",
default: "h-[min(24rem,calc(100vh-1rem))]",
lg: "h-[min(36rem,calc(100vh-1rem))]",
full: "h-screen",
}[size]
const handle = swipeToClose ? (
<div
data-slot="drawer-handle"
aria-hidden="true"
className={cn(
"flex shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing",
horizontal
? cn("absolute inset-y-0 z-10 w-4", side === "right" ? "left-0" : "right-0")
: "h-6 w-full"
)}
onPointerDown={(event) => dragControls.start(event)}
>
<span
className={cn(
"bg-muted-foreground/30 rounded-full",
horizontal ? "h-10 w-1" : "h-1 w-10"
)}
/>
</div>
) : null
const panel = (
<MotionContent
ref={setPanelRef}
forceMount
data-slot="drawer-content"
data-side={side}
data-size={size}
className={cn(
"bg-background fixed z-50 flex flex-col shadow-lg outline-none",
placement,
dimensions,
className
)}
initial={hidden}
animate={reduceMotion ? { opacity: 1 } : { x: 0, y: 0 }}
exit={{
...hidden,
transition: reduceMotion
? { duration: 0.12 }
: { duration: 0.24, ease: [0.4, 0, 1, 1] },
}}
transition={reduceMotion ? { duration: 0.12 } : PANEL_SPRING}
drag={swipeToClose && !reduceMotion ? (horizontal ? "x" : "y") : false}
dragListener={false}
dragControls={dragControls}
dragConstraints={{ top: 0, right: 0, bottom: 0, left: 0 }}
dragElastic={
horizontal
? { left: exitSign < 0 ? 1 : 0.04, right: exitSign > 0 ? 1 : 0.04 }
: { top: exitSign < 0 ? 1 : 0.04, bottom: exitSign > 0 ? 1 : 0.04 }
}
onDragEnd={(_, info) => {
const panel = panelRef.current
const extent = horizontal
? (panel?.offsetWidth ?? 0)
: (panel?.offsetHeight ?? 0)
const offset = (horizontal ? info.offset.x : info.offset.y) * exitSign
const velocity = (horizontal ? info.velocity.x : info.velocity.y) * exitSign
if (offset > extent * 0.25 || velocity > 600) setOpen(false)
}}
{...(props as unknown as React.ComponentProps<
typeof MotionContent
>)}
>
{side === "top" ? null : handle}
{children}
{side === "top" ? handle : null}
{hideClose ? null : (
<DrawerPrimitive.Close
className={cn(
"text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 absolute right-4 top-4 flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2",
swipeToClose && side === "bottom" && "top-8"
)}
>
<XIcon className="size-4" />
<span className="sr-only">关闭</span>
</DrawerPrimitive.Close>
)}
</MotionContent>
)
return (
<AnimatePresence>
{open ? (
<DrawerPortal forceMount>
{/*
The overlay owns the panel in the React tree so portalled controls
inside the drawer (select, popover, date picker) stay scrollable
under Radix's scroll lock. The dimmed backdrop is its own layer, so
fading it never fades the panel.
*/}
{modal ? (
<DrawerPrimitive.Overlay
forceMount
data-slot="drawer-overlay"
className="fixed inset-0 z-50"
>
<motion.div
data-slot="drawer-backdrop"
aria-hidden="true"
className="bg-overlay absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.22, ease: "easeOut" }}
/>
{panel}
</DrawerPrimitive.Overlay>
) : (
panel
)}
</DrawerPortal>
) : null}
</AnimatePresence>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("grid gap-1.5 border-b px-5 py-4 pr-14", className)}
{...props}
/>
)
}
function DrawerBody({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-body"
className={cn("min-h-0 flex-1 overflow-y-auto px-5 py-4", className)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn(
"mt-auto flex flex-col-reverse gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-base font-semibold tracking-tight", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm leading-5", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
}
属性 Props
Drawer (根组件)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| open | boolean | — | 受控模式下的打开状态,需配合 onOpenChange 使用。 |
| defaultOpen | boolean | false | 非受控模式下的初始打开状态。 |
| onOpenChange | (open: boolean) => void | — | 抽屉打开或关闭状态变化时的回调函数。 |
| modal | boolean | true | 是否以模态形式呈现(锁定页面滚动并阻止外部交互)。 |
DrawerContent
DrawerContent 为滑出面板的主体容器:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| side | "top" | "right" | "bottom" | "left" | "right" | 抽屉从屏幕的哪一侧边缘滑入。 |
| size | "sm" | "default" | "lg" | "full" | "default" | 抽屉面板的预设尺寸密度(水平方向为宽度,垂直方向为高度)。 |
| hideClose | boolean | false | 是否隐藏右上角内置的快捷关闭按钮图标。 |
| swipeToClose | boolean | false | 在面板内侧边缘渲染拖拽把手。按住把手朝滑入边缘拖动,超过面板尺寸的 1/4 或快速甩动即可关闭;反方向拖动会有阻尼回弹。开启减弱动态效果时不可拖拽。 |
| className | string | — | 应用于抽屉主容器的自定义 CSS 类名。 |
DrawerHeader / DrawerBody / DrawerFooter / DrawerTitle / DrawerDescription
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于相应结构容器的额外类名。 |
| children | React.ReactNode | — | 容器内部渲染的子元素。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onOpenChange | (open: boolean) => void | — | 抽屉可见状态发生改变时调用(包括点击触发器、遮罩、Esc 键或关闭按钮)。 |
| onPointerDownOutside | (event: PointerDownOutsideEvent) => void | — | 在抽屉浮层外部点击时触发,调用 event.preventDefault() 可阻止点击遮罩关闭抽屉。 |
| onEscapeKeyDown | (event: KeyboardEvent) => void | — | 按下键盘 Esc 键时触发,调用 event.preventDefault() 可阻止快捷键关闭行为。 |
使用场景与设计规范
Drawer 在不离开当前页面上下文的前提下,为用户提供大容量、深层级的交互空间:
- 组件选型对比:
- Drawer vs Dialog:
Dialog适合强聚焦、短小决策或高危警告;Drawer沿边缘延伸,纵向滚动空间充裕,适合多字段表单、明细查看、历史记录列表。 - Drawer vs 独立路由页面:当查看或编辑内容与当前主列表强关联,且操作完成后需要立即在主界面反映(不丢失主页滚动位置和筛选条件)时,优先采用
Drawer代替路由跳转。
- Drawer vs Dialog:
- 经典三段式结构规范:
- DrawerHeader:固定在顶部,展示清晰的业务标题与辅助描述,右上角提供关闭图标。
- DrawerBody:自适应撑满剩余高度,内置
overflow-y-auto滚动容器,确保长内容流畅浏览。 - DrawerFooter:吸附在底部,保持主要操作(如“保存”、“提交”)始终可见,避免随长内容滚出视口。
- 边缘选择建议:
- Right(右侧):桌面端最标准的详情面板与侧边工作台。
- Bottom(底部):移动端触控优选(Bottom Sheet)或全局多选批量操作栏。
- Left(左侧):收起式侧边导航或多级文档目录。
场景示例
详情预览面板
在表格或主列表中点击单条记录,从右侧拉出抽屉查看详尽的发布状态与元数据指标:
Loading…
四向滑出位置
支持配置 side="top" | "right" | "bottom" | "left",以满足移动端 Bottom Sheet、左侧导航或顶部搜索托盘等多样化布局需求:
Loading…
复杂表单与固定底部栏
将 DrawerHeader、DrawerBody 与 DrawerFooter 组合在 <form> 内部,实现内容独立滚动、操作栏固定吸底的表单抽屉:
Loading…
尺寸预设与响应式
提供 sm、default、lg、full 四种尺寸预设,并在小屏设备上自动计算 min(width, 100vw - 1rem) 防止溢出:
Loading…
手势拖拽关闭
开启 swipeToClose 后,底部抽屉顶部会出现拖拽把手。把手是唯一的拖拽起点,面板内的按钮、输入框与滚动区域不受手势干扰:
Loading…
无障碍与交互 Accessibility
- ARIA 规范:
- 基于 Radix Dialog 语义,自动设置
role="dialog"与aria-modal="true"。 DrawerTitle与DrawerDescription自动生成 ID 并绑定aria-labelledby和aria-describedby。
- 基于 Radix Dialog 语义,自动设置
- 键盘与焦点管理:
- 焦点捕获(Focus Trap):抽屉滑出后,焦点被锁定在抽屉内部,键盘用户无法将焦点移到背后的被遮挡元素上。
- 焦点归还:抽屉关闭后,焦点自动精确回到唤起该抽屉的触发按钮。
- 快捷键:按下 Esc 即可平滑关闭抽屉。
- 滚动锁定(Body Scroll Lock):
- 模态抽屉打开期间,背景页面的
<body>会自动锁定滚动并补充滚动条占位,防止双重滚动与页面抖动。
- 模态抽屉打开期间,背景页面的
- 动效减弱:
- 自动支持
prefers-reduced-motion,开启后禁用弹性位移动画与拖拽手势,转为淡入淡出。
- 自动支持
- 非模态模式:
- 设置
modal={false}时不渲染遮罩、不锁定滚动,面板与页面可同时交互。
- 设置