返回博客

HeroUI v3 Modal 偏左不居中?Backdrop 是包裹层不是自闭合标签

在某后台管理系统里接入了 HeroUI v3 的弹窗,按官网示例写完,点按钮弹出来——背景遮罩全屏没问题,但对话框本身跑到了页面左侧,完全没有居中。控制台还多了一条 React 水合警告 <button> cannot be a descendant of <button>。两个症状并存,很容易误以为是同一个 bug,但其实是两个独立的坑叠在一起。

现象

  1. Modal 打开后,对话框出现在视口左侧(甚至左下角),而不是水平垂直居中。
  2. 灰色背景遮罩覆盖了全屏,看起来正常,但与对话框"脱钩"——遮罩在,内容不在它中间。
  3. 控制台报错:In HTML, <button> cannot be a descendant of <button>,堆栈里有 ModalCloseTrigger → CloseButtonRoot → Button
  4. 原本放在 Footer 里的"取消"按钮,跑到了对话框右上角,绝对定位悬浮。

根因

这里有两个坑,各自独立:

坑一:Modal.Backdrop 必须包裹 Modal.Container,不是兄弟节点

看源码(node_modules/@heroui/react/dist/components/modal/modal.js):

const ModalBackdrop = ({ children, ... }) => {
  return jsx(ModalOverlay, {          // react-aria ModalOverlay
    className: ...,
    "data-slot": "modal-backdrop",
    ...props,
    children: renderProps => jsxs(ModalContext, {
      value: updatedModalContext,     // 提供 placement/slots 给子树
      children: [children, " "]
    })
  });
};

Modal.Backdrop 本质是 react-aria 的 <ModalOverlay>,它做了三件事:

  1. 通过 React Portal 把子树挂到 document.body,获得 position: fixed; inset: 0 全屏层;
  2. --visual-viewport-height CSS 变量注入自身 inline style,供子代定高;
  3. 通过 ModalContextplacementcenter / top / bottom)提供给 Modal.Container

如果把 <Modal.Backdrop /> 写成自闭合标签,Modal.Container 就成了它的兄弟,脱离了 ModalOverlay 子树。Portal 没有了、viewport 状态没有了、placement context 没有了——Modal.Container 退回 position: static,渲染在 React 树所在的 DOM 位置(通常是页面左侧的某个 flex/grid 容器里)。

关键:背景遮罩依然正常,因为 Modal.Backdrop 本身的 position: fixed; inset: 0 没丢。所以视觉上遮罩全屏、对话框却偏左,容易误判为 CSS 层叠问题。

坑二:Modal.CloseTrigger 本身就是一个 <button>

const ModalCloseTrigger = ({ className, ...rest }) => {
  return jsx(CloseButton, {   // react-aria Button,slot="close"
    className: ...,
    "data-slot": "modal-close-trigger",
    slot: "close",
    ...rest
  });
};

它对应的 CSS:

.modal__close-trigger { @apply absolute top-4 right-4; }

Modal.CloseTrigger 就是右上角那个"✕"图标按钮,自带绝对定位。如果把自己的 <Button>取消</Button> 套进去,DOM 就是 <button><button>取消</button></button>,React 19 报水合错误,"取消"也被连带定位到右上角。

坑:两个症状可能同时存在,修了控制台错误不等于修好了偏位

TRAP 2(按钮嵌套)有显眼的控制台报错,TRAP 1(Backdrop 平级)是静默的。很容易只修 TRAP 2,清了报错,对话框还是偏左——这是 TRAP 1 没动的证明。两个坑要一起修。

用 DevTools 快速确认 TRAP 1:

const c = document.querySelector('[data-slot="modal-container"]');
console.log(c.parentElement.className, getComputedStyle(c).position);

position"static",或父节点 className 里没有 modal__backdrop,说明 TRAP 1 还在。

正解

错误写法(两个坑都踩到):

<Modal isOpen={open} onOpenChange={setOpen}>
  <Modal.Backdrop />                          {/* TRAP 1:自闭合兄弟节点 */}
  <Modal.Container size="md" placement="center">
    <Modal.Dialog>
      <Modal.Header><Modal.Heading>标题</Modal.Heading></Modal.Header>
      <Modal.Body>...</Modal.Body>
      <Modal.Footer>
        <Modal.CloseTrigger>                  {/* TRAP 2:套了 Button */}
          <Button variant="outline">取消</Button>
        </Modal.CloseTrigger>
        <Button onPress={handleSave}>保存</Button>
      </Modal.Footer>
    </Modal.Dialog>
  </Modal.Container>
</Modal>

正确写法:

<Modal isOpen={open} onOpenChange={setOpen}>
  <Modal.Backdrop>                            {/* 包裹层,Container 放进来 */}
    <Modal.Container size="md" placement="center">
      <Modal.Dialog>
        <Modal.Header><Modal.Heading>标题</Modal.Heading></Modal.Header>
        <Modal.Body>...</Modal.Body>
        <Modal.Footer>
          <Button variant="outline" onPress={() => setOpen(false)}>取消</Button>
          <Button onPress={handleSave}>保存</Button>
        </Modal.Footer>
      </Modal.Dialog>
    </Modal.Container>
  </Modal.Backdrop>
</Modal>
正解:Container 必须是 Backdrop 的子节点;取消按钮直接放 Footer
  • <Modal.Backdrop> 作为包裹层,<Modal.Container> 嵌套在其内部,才能获得 Portal + ModalContext。
  • "取消"用普通 <Button onPress={() => setOpen(false)}> 放在 Modal.Footer 里,不要碰 Modal.CloseTrigger
  • 如果真的需要右上角"✕",用 <Modal.CloseTrigger /> 无子节点,或只传图标节点,绝不传 <Button>

排查备忘

修复后用 DevTools 验证:

  1. document.querySelector('[data-slot="modal-container"]').parentElement.classNamemodal__backdrop
  2. document.querySelector('[data-slot="modal-dialog"]').getBoundingClientRect()x ≈ (viewport.width - dialog.width) / 2
  3. 控制台无 <button> cannot be a descendant of <button> 警告。

一句话外卖

组件库里名字像"背景层"的东西,如果自闭合时什么视觉效果都没有,八成它是包裹层——先检查 DOM 嵌套,再去怀疑 CSS。