返回博客

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 之类的定位类,确实能让面板贴着右边出现,但:

  1. 关闭时没有滑出动画,直接消失;
  2. 面板底部的操作按钮组不固定在底部,而是紧跟在内容下面;
  3. 误以为需要 Positioner(Tooltip/Popover 用的那个),结果套上去之后 Popup 位置完全乱掉。

根因

坑一:Positioner 不属于 Dialog

Base UI 的 Positioner 只用于锚定型 overlay(Tooltip、Popover),它需要一个参考元素来计算位置。Dialog/Drawer 是全局遮罩型,Popup 自己 position: fixed,你直接用 CSS 类控制位置。把 Positioner 套进来,它会尝试计算相对某个元素的偏移,结果一塌糊涂。

坑:Dialog Popup 没有 Positioner

Positioner 只给 Tooltip / Popover 用。Dialog 和 Drawer 的 Popupposition: 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 offset 必须放在 data-[ending-style],不是 base

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 的截图才会暴露。

坑:block 容器里 mt-auto 不等于钉底

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 动画运行时。

正解:Dialog 引擎 + CVA side 变体 + data-[] 动画钩子
  • 四个方向用 CVA side 变体管理;
  • 基础样式 = 开启态(translate-0),data-[starting-style]data-[ending-style] 各打关闭态 translate;
  • 内容区 flex flex-col,footer mt-auto 钉底;
  • 不要套 Positioner,不要引入第二个 overlay 库。

一句话外卖

Base UI Dialog 的 Popup 是 position: fixed,你自己用 CSS 控制位置;data-[ending-style] 是退场状态,不是「关闭状态默认值」——搞反了动画不播,搞对了进出都顺滑。