版面與遮擋:z-index、sticky、捲動、破版
為什麼元素會互相蓋住、為什麼 z-index 加到 9999 還是沒用、錨點被 header 吃掉怎麼修,以及橫向捲動的六個元凶。
1. z-index 不是全域排名
z-index 只在同一個 stacking context 裡比較。
一個元素只要有下列任何一項,就會建立新的 stacking context,它的子孫再高的 z-index 也出不去:
position不是static且有z-indexopacity< 1transform、filter、backdrop-filter、perspective、will-changeisolation: isolatecontain: paint- flex / grid 的子項目且有
z-index
所以「我的 modal z-index 是 9999,還是被蓋住」的答案通常是:它的父層建立了一個 stacking context,而那個父層的層級比較低。
正確做法
一、定義層級表,不要臨場想數字:
:root {
--z-base: 0;
--z-sticky: 100;
--z-dropdown: 200;
--z-overlay: 300;
--z-toast: 400;
}
二、在元件根部用 isolation: isolate,把內部的 z-index 關在裡面:
.ds-scope { isolation: isolate; }
這樣模組內部可以自由用 z-index 1、2、20,不可能跟外面打架。
三、浮層 portal 到 body。彈窗、下拉、tooltip 只要留在原地,就永遠會被某個 overflow: hidden 或某個 transform 咬到。原生 <dialog> + showModal() 會自動進入 top layer,完全繞開整個 z-index 問題 —— 這是目前最好的解法。
2. Sticky 沒有黏住
position: sticky 失效的四個原因,照這個順序檢查:
- 沒有指定
top(或bottom)。sticky沒有偏移值等於static。 - 父層有
overflow: hidden/auto/scroll。sticky 是相對最近的捲動容器,父層一旦變成捲動容器,它就在那裡面黏 —— 看起來像沒作用。 - 父層高度不夠。sticky 只能在父層的範圍內黏,父層一結束它就走了。
- 父層是 flex 且
align-items: stretch之外的值,導致元素高度剛好等於父層。
第 2 點最常見,而且來源常常是遠處的一句 overflow-x: hidden。
3. 錨點被 header 吃掉
點目錄跳過去,標題卡在 sticky header 後面。
html { scroll-padding-top: calc(var(--nav-height) + 24px); }
或針對個別元素:
h2 { scroll-margin-top: calc(var(--nav-height) + 24px); }
用這兩個,不要用「加一個透明的 padding 元素」那種老招 —— 那會弄壞版面。
4. 橫向捲動:六個元凶
先找出兇手:
const w = document.documentElement.clientWidth;
[...document.querySelectorAll('*')].filter(el => el.getBoundingClientRect().right > w + 1)
| 元凶 | 修法 |
|---|---|
| 寬表格 | 包一層 overflow-x: auto |
| 長英數字串(網址、hash) | overflow-wrap: anywhere |
width: 100vw |
用 width: 100%。100vw 不扣捲軸寬度 |
| 負 margin 的滿版區塊 | 用 grid 的 full-bleed 技巧,或 margin-inline: calc(50% - 50vw) 搭配 overflow-x: clip |
| 絕對定位的裝飾 | 父層 overflow: clip |
固定 min-width 的 flex 子項 |
min-width: 0(flex 子項預設 min-width: auto,不會縮) |
body { overflow-x: clip } 是止血,不是修好。用 clip 不要用 hidden —— hidden 會讓 body 變成捲動容器,順便弄壞所有 sticky。
5. Flex 與 Grid 不肯縮
flex 子項預設 min-width: auto,意思是「不能小於內容」。一個裡面有長文字的 flex 子項,會把整列撐爆。
.flex-child { min-width: 0; }
Grid 同理,用 minmax(0, 1fr) 而不是 1fr:
grid-template-columns: minmax(0, 1fr) 220px;
還有 grid 子項的 min-height: 0 —— 沒有它,「只有內容區捲動」的後台版面做不出來:
.main { display: grid; grid-template-rows: auto 1fr; min-height: 0; }
.content { min-height: 0; overflow-y: auto; }
6. 版面跳動(CLS)
| 原因 | 修法 |
|---|---|
| 圖片載入 | 每張 <img> 給 width height,或 aspect-ratio |
| 字型切換 | font-display: swap + 選擇 metrics 接近的 fallback |
| 動態插入的橫幅 | 預留空間,或用 position: fixed 不影響版面 |
| 骨架屏尺寸不對 | 骨架要跟真實內容一樣高 |
| 按鈕文字變化 | 給 min-width,或用等寬字 |
| 捲軸出現 | html { scrollbar-gutter: stable; } |
最後一項很少人做但效果明顯:頁面從短變長時捲軸出現,整個版面會往左跳幾像素。
7. 滿版區塊
在有 max-width 的容器裡讓某個區塊出血到螢幕兩側:
.full-bleed {
width: 100vw;
margin-inline: calc(50% - 50vw);
}
必須搭配祖先的 overflow-x: clip,否則 100vw 會製造橫向捲動(因為 vw 不扣捲軸)。
更乾淨的做法是 grid:
.page {
display: grid;
grid-template-columns:
[full-start] minmax(var(--gutter), 1fr)
[content-start] min(100% - var(--gutter) * 2, var(--container)) [content-end]
minmax(var(--gutter), 1fr) [full-end];
}
.page > * { grid-column: content; }
.page > .bleed { grid-column: full; }
8. 手機的 100vh
100vh 在行動瀏覽器包含了會消失的網址列,於是「滿版」永遠比螢幕高一截。
.hero {
min-height: 100svh; /* small viewport height:網址列展開時的高度 */
}
svh / lvh / dvh 三者:svh 最保守(不會超出),dvh 會跟著網址列變 —— 但 dvh 會導致捲動時版面抖動,慎用。
9. 檢查清單
- 有一張 z-index 層級表,沒有臨場亂數
- 模組根部有
isolation: isolate - 彈窗用原生
<dialog>或 portal 到 body -
html有scroll-padding-top - 360px 下沒有橫向捲動
- 寬表格包在
overflow-x: auto裡 - flex 子項該加
min-width: 0的都加了 - 所有圖片都有尺寸
- 滿版用
100svh不是100vh