Modal 一打开页面就跳:place-items 和 place-content 根本不是一回事
把应用 shell 塞进全屏居中布局,最省事的写法是:
body {
min-height: 100dvh;
display: grid;
place-items: center;
}页面加载时,shell 完美居中。接下来你打开一个基于 Portal 的弹窗——Radix Dialog、Headless UI Dialog、或者任何 ReactDOM.createPortal(child, document.body) 实现——shell 整体往上跳了三四十像素。弹窗关掉,又弹回来。
第一反应:弹窗库的 scroll-lock 在作怪(它会往 <html> 加 overflow: hidden + padding-right)。于是你加上 html { scrollbar-gutter: stable }——没用,还是跳。
这说明根因根本不在 scroll-lock,在别处。
现象
- 弹窗打开时 shell 垂直位移,水平没有变化(水平跳动才是 scrollbar 消失的特征)。
- 位移量和视口高度、shell 高度之差相关,视口越高、shell 越矮,跳得越多。
- DevTools 实时查看
<body>的子节点:弹窗打开后多了一个<div>Portal 容器,弹窗关闭后消失。
根因
place-items: center 是 align-items: center + justify-items: center 的缩写,管的是格子内部的对齐。align-content 没有被设置,保持默认值 normal,在 CSS Grid 里 normal 对隐式轨道等同于 stretch——把所有行拉伸来填满容器高度。
只有一个子元素时,只有一行,这行撑满整个 min-height: 100dvh,shell 在里面居中,视觉上居中没问题。
Portal 打开后,<body> 多了一个直接子节点(Portal 包装 div)。Grid 自动新增一行来容纳它。现在有两行了,stretch 空间被重新平均分配:
视口高 1440,shell 高 1080,body min-height 1440:
修复前(place-items: center,1 行):
行 1 拉伸到 1440,shell 在里面居中 → shell.top = 180
弹窗打开(2 行:shell + Portal 容器):
两行内容总高 = 1080 + 0 = 1080
剩余 360,各行均分 +180 → 行 1 = 1260,行 2 = 180
shell 在行 1 里居中 → shell.top = 90 ← 往上跳了 90px
Portal 容器的 position: fixed 内容不参与布局,但 容器本身作为 Grid 行存在,足以触发重分配。
place-items: center 只管格子内对齐,不管轨道集合如何排布。align-content 保持默认 stretch,行数一变、每行分到的高度就重算,导致 shell 垂直位置漂移。
正解
把 place-items: center 换成 place-content: center:
body {
min-height: 100dvh;
display: grid;
place-content: center; /* align-content + justify-content */
justify-items: center; /* 若需要子项在格子内水平居中保留此行 */
}align-content: center 把所有轨道作为一个整体在容器里居中,轨道不再 stretch。Portal 新增的 0 高度行只是往末尾追加了一行 0 高,整个轨道组的顶部位置不变:
2 行(1080 + 0),place-content: center:
总高 1080,居中 → 顶部偏移 180
行 1 在 180..1260,行 2 在 1260
shell.top 依旧 = 180 ← 稳住了
place-content: center 令全部轨道打包居中,新增的 0 高 Portal 行追加在末尾,不影响第一行的位置。
附坑:justify-items 会挤压没有显式宽度的页面
加了 justify-items: center 后,每个 <body> 的直接子节点会被收窄到内容自身宽度,而不是撑满视口。主 shell 通常有 width: min(100vw, ...) 所以没问题,但如果项目里还有其他独立页面(登录页、404 页、营销落地页),它们的根 div 没写宽度,就会被挤到中间一条,两侧漏出 body 背景色。
修法:给受影响页面的最外层 wrapper 加显式宽度,不要去动 body 样式:
// 受影响页面的根 wrapper
<div className="min-h-screen w-screen bg-white">
{/* 页面内容 */}
</div>w-screen 强制 Grid 列宽等于视口宽,justify-content: center 没有剩余空间可分配,子项自然左对齐到 0。
鉴别:垂直跳 vs 水平跳
打开弹窗时页面抖动,一定先判断方向,两种根因完全不同:
| 现象 | 根因 | 修法 |
|---|---|---|
| 水平位移 ~15px | 弹窗 scroll-lock 锁定 overflow: hidden 后滚动条消失、页面变宽 | html { scrollbar-gutter: stable } |
| 垂直位移,且 scrollbar-gutter 无效 | Grid place-items 行重分配 | place-items → place-content(本文) |
一句话外卖
place-items管格子内对齐,place-content管轨道集合对齐——两者长得像,但行数一变就原形毕露;有 Portal 的项目用后者。