Base UI 没有 Drawer?用 Dialog + transform 自己搓一个
在给某设计系统补 Drawer 组件时,我发现 Base UI rc.0 根本没有 @base-ui-components/react/drawer。文档里没有,npm 上也找不到。想引入第二个 overlay 库(React Aria / Radix)又觉得太重——项目里已经用了 Base UI Dialog,焦点管理、Esc 关闭、点击外部关闭都有现成的,重复引入等于把这套基础设施造两遍。
翻了一下 Base UI Dialog 的源码,发现一个 Drawer 本质上就是「固定在屏幕边缘、靠 transform: translateX/Y 滑进来的 Dialog」。完全可以复用 Portal + Backdrop + Popup 这套引擎,自己用 CSS 描述位置和动画。
现象
用现有 Dialog 组件稍作改造,加上 fixed right-0 inset-y-0 h-full w-80 之类的定位类,确实能让面板贴着右边出现,但:
- 关闭时没有滑出动画,直接消失;
- 面板底部的操作按钮组不固定在底部,而是紧跟在内容下面;
- 误以为需要
Positioner(Tooltip/Popover 用的那个),结果套上去之后 Popup 位置完全乱掉。
根因
坑一:Positioner 不属于 Dialog
Base UI 的 Positioner 只用于锚定型 overlay(Tooltip、Popover),它需要一个参考元素来计算位置。Dialog/Drawer 是全局遮罩型,Popup 自己 position: fixed,你直接用 CSS 类控制位置。把 Positioner 套进来,它会尝试计算相对某个元素的偏移,结果一塌糊涂。
Positioner 只给 Tooltip / Popover 用。Dialog 和 Drawer 的 Popup 是 position: fixed,直接用 Tailwind 工具类定位到屏幕边缘,不要套 Positioner。
坑二:动画状态要打在 data-[starting-style] 和 data-[ending-style] 上
Base UI 的 Popup 和 Backdrop 暴露了两个过渡钩子:data-[starting-style](进场开始帧)和 data-[ending-style](退场结束帧)。正确做法是:基础样式 = 开启状态(translate-0),两个 data 属性上各自打上「推出屏幕外」的 translate 值。
很多人的第一直觉是把关闭时的偏移打在基础类上,结果 Drawer 默认就在屏幕外,开启后才滑进来——逻辑反了,而且退场动画也不会播放(Base UI 会等 transition 结束再卸载 DOM,但你没给退场状态设不同的值,它不知道要动)。
把 translate-x-full 打在基础类上,Drawer 默认就在屏幕外,data-[ending-style] 与基础值相同,退场动画一帧都不播。
坑三:content wrapper 必须是 flex-col,footer 才能钉底
Popup 内部的内容区如果是普通 block,给 footer 加 mt-auto 只会让它浮在 header 正下方(因为 block 流里 auto margin 不会把 footer 推到容器底部,除非容器是 flex)。这个 bug 在 jsdom 单测里根本看不出来,只有截开着的 Drawer 的截图才会暴露。
Drawer 里的内容区必须是 flex flex-col,footer mt-auto 才能真正推到底部。jsdom 测试无法检测这类布局错误,需要打开 Drawer 后截图验证。
正解
用 CVA 把四个方向的定位、边框、和关闭态 translate 映射成一套变体:
export const drawerVariants = cva(
[
"fixed z-50 flex flex-col gap-1 bg-surface border-border p-6 shadow-xl outline-none",
"transition-transform",
],
{
variants: {
side: {
right: "inset-y-0 right-0 h-full w-[min(90vw,24rem)] border-l data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full",
left: "inset-y-0 left-0 h-full w-[min(90vw,24rem)] border-r data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full",
top: "inset-x-0 top-0 w-full h-[min(90vh,20rem)] border-b data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full",
bottom: "inset-x-0 bottom-0 w-full h-[min(90vh,20rem)] border-t data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full",
},
},
defaultVariants: { side: "right" },
},
);Popup 结构里,内容区用 flex flex-col,footer 用 mt-auto:
<BaseDialog.Portal>
<BaseDialog.Backdrop
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity
data-[starting-style]:opacity-0 data-[ending-style]:opacity-0"
style={overlayTransition}
/>
<BaseDialog.Popup
className={cn(drawerVariants({ side }), className)}
style={overlayTransition}
>
{title && <BaseDialog.Title>{title}</BaseDialog.Title>}
{/* flex-col 让 footer mt-auto 能真正钉底 */}
<div className="mt-2 flex flex-1 flex-col">{children}</div>
</BaseDialog.Popup>
</BaseDialog.Portal>overlayTransition 复用项目里 Dialog 已有的 motion token(transitionDuration + transitionTimingFunction),不引入任何 JS 动画运行时。
- 四个方向用 CVA
side变体管理; - 基础样式 = 开启态(translate-0),
data-[starting-style]和data-[ending-style]各打关闭态 translate; - 内容区
flex flex-col,footermt-auto钉底; - 不要套 Positioner,不要引入第二个 overlay 库。
一句话外卖
Base UI Dialog 的 Popup 是
position: fixed,你自己用 CSS 控制位置;data-[ending-style]是退场状态,不是「关闭状态默认值」——搞反了动画不播,搞对了进出都顺滑。