返回博客

Modal 一打开页面就跳:place-itemsplace-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: centeralign-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 不锁行数,动态 Portal 子节点会重分配 stretch 空间

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 锁定轨道集合,Portal 追加行不影响既有行

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-itemsplace-content(本文) |

一句话外卖

place-items 管格子内对齐,place-content 管轨道集合对齐——两者长得像,但行数一变就原形毕露;有 Portal 的项目用后者。