HeroUI v3 Modal 偏左不居中?Backdrop 是包裹层不是自闭合标签
在某后台管理系统里接入了 HeroUI v3 的弹窗,按官网示例写完,点按钮弹出来——背景遮罩全屏没问题,但对话框本身跑到了页面左侧,完全没有居中。控制台还多了一条 React 水合警告 <button> cannot be a descendant of <button>。两个症状并存,很容易误以为是同一个 bug,但其实是两个独立的坑叠在一起。
现象
- Modal 打开后,对话框出现在视口左侧(甚至左下角),而不是水平垂直居中。
- 灰色背景遮罩覆盖了全屏,看起来正常,但与对话框"脱钩"——遮罩在,内容不在它中间。
- 控制台报错:
In HTML, <button> cannot be a descendant of <button>,堆栈里有ModalCloseTrigger → CloseButtonRoot → Button。 - 原本放在 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>,它做了三件事:
- 通过 React Portal 把子树挂到
document.body,获得position: fixed; inset: 0全屏层; - 把
--visual-viewport-heightCSS 变量注入自身 inline style,供子代定高; - 通过
ModalContext把placement(center/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><Modal.Backdrop>作为包裹层,<Modal.Container>嵌套在其内部,才能获得 Portal + ModalContext。- "取消"用普通
<Button onPress={() => setOpen(false)}>放在Modal.Footer里,不要碰Modal.CloseTrigger。 - 如果真的需要右上角"✕",用
<Modal.CloseTrigger />无子节点,或只传图标节点,绝不传<Button>。
排查备忘
修复后用 DevTools 验证:
document.querySelector('[data-slot="modal-container"]').parentElement.className含modal__backdrop。document.querySelector('[data-slot="modal-dialog"]').getBoundingClientRect()的x ≈ (viewport.width - dialog.width) / 2。- 控制台无
<button> cannot be a descendant of <button>警告。
一句话外卖
组件库里名字像"背景层"的东西,如果自闭合时什么视觉效果都没有,八成它是包裹层——先检查 DOM 嵌套,再去怀疑 CSS。