返回博客

Dialog 逃出手机框:Portal + fixed 联手坑穿 Demo 展示页

做产品演示页时,常见的做法是在桌面视口中央放一个手机框(约 400×844 的 div),把小程序 / App 的交互在浏览器里"装进去"跑。当我把一个底部抽屉(Bottom Drawer)集成进去,点击触发时——抽屉确实打开了,但它直接铺满了整个桌面屏幕,遮罩覆盖全视口,那个精心做好的手机框在黑幕后消失了。

现象

  • Drawer 打开后,遮罩(Backdrop)覆盖整个浏览器视口,而非手机框内部。
  • 抽屉面板吸附在屏幕底部,宽度等于视口宽,与手机框完全脱离。
  • 关闭动画结束后手机框恢复正常,就好像什么都没发生过。

根因

Base UI(以及 Radix UI)的 Dialog.Portal 默认把整个弹层 DOM 挂载到 <body> 末尾。配合 Backdrop 和 Popup 上的 position: fixed,它们的包含块(Containing Block)是视口,而不是那个手机框 div

无论手机框 div 设置了 overflow-hidden 还是 z-indexfixed 元素根本不在它的渲染子树里,自然不受任何裁剪或约束。

坑:只改 container 是不够的

Dialog.Portal 提供了 container prop,可以指定挂载目标——很多人改完就以为解决了。

但挂载目标改了,Backdrop 和 Popup 的 CSS position: fixed 还在。fixed 的包含块始终是视口(除非祖先有 transform/filter 等),所以遮罩依然全屏,面板依然贴着屏幕底部。

必须两步同时做:改挂载容器 + 把 fixed 换成 absolute

正解

关键:container + fixed → absolute 缺一不可,手机框必须是 relative overflow-hidden

第一步:组件层——条件切换 position

fixed/absolutecva 的 base 类中剔除,改为运行时根据 container 是否存在来决定:

export function DrawerContent({ side = "bottom", container, className, ...props }) {
  // 有 container 时用 absolute,否则用 fixed(全屏模式)
  const place = container != null ? "absolute" : "fixed";
 
  return (
    <BaseDialog.Portal container={container}>
      <BaseDialog.Backdrop className={cn(place, "inset-0 z-40 bg-black/40")} />
      <BaseDialog.Popup
        className={cn(place, drawerVariants({ side }), className)}
        {...props}
      />
    </BaseDialog.Portal>
  );
}
 
// container prop 类型直接从库导出,支持 element 或 RefObject
type DrawerContentProps = {
  container?: ComponentProps<typeof BaseDialog.Portal>["container"];
  // ...其他 props
};

第二步:消费层——给手机框挂 ref 并传入

const frameRef = useRef<HTMLDivElement>(null);
 
// 手机框:relative + overflow-hidden 是裁剪 absolute 子元素的关键
<div
  ref={frameRef}
  className="relative h-[844px] w-[400px] overflow-hidden rounded-[48px] …"
>
  {/* 手机框内容 */}
</div>
 
<Drawer open={open} onOpenChange={setOpen}>
  <DrawerContent side="bottom" container={frameRef} className="rounded-t-[20px]">
    {/* 抽屉内容 */}
  </DrawerContent>
</Drawer>
正解:container + absolute 双保险
  • Dialog.Portal container={frameRef} — 把 DOM 挂进手机框,而非 body
  • Backdrop/Popup 用 absolute inset-0 — 包含块变成手机框,遮罩和面板都被裁在框内
  • 手机框加 overflow-hidden — 把超出边界的 absolute 子元素截掉,rounded 圆角也因此生效
附注:高度单位别用 vh

抽屉高度不要写 h-[70vh]——vh 永远相对视口,即使 portal 进了小容器也会溢出手机框。

改用相对父容器的百分比或固定像素:h-[70%]max-h-[600px]

一句话外卖

Base UI / Radix 的 Dialog 要"装进"局部容器,必须同时做两件事:Portal container 改挂载目标 + Backdrop/Popup 从 fixedabsolute;手机框自己要 relative overflow-hidden,缺任何一环都没用。