跳到主要內容

版面與遮擋:z-index、sticky、捲動、破版

為什麼元素會互相蓋住、為什麼 z-index 加到 9999 還是沒用、錨點被 header 吃掉怎麼修,以及橫向捲動的六個元凶。

約 7 分鐘 · layout-and-overlap.md

1. z-index 不是全域排名

z-index 只在同一個 stacking context 裡比較。

一個元素只要有下列任何一項,就會建立新的 stacking context,它的子孫再高的 z-index 也出不去:

  • position 不是 static 且有 z-index
  • opacity < 1
  • transformfilterbackdrop-filterperspectivewill-change
  • isolation: isolate
  • contain: 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 失效的四個原因,照這個順序檢查:

  1. 沒有指定 top(或 bottom)。sticky 沒有偏移值等於 static
  2. 父層有 overflow: hiddenautoscroll。sticky 是相對最近的捲動容器,父層一旦變成捲動容器,它就在那裡面黏 —— 看起來像沒作用。
  3. 父層高度不夠。sticky 只能在父層的範圍內黏,父層一結束它就走了。
  4. 父層是 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
  • htmlscroll-padding-top
  • 360px 下沒有橫向捲動
  • 寬表格包在 overflow-x: auto
  • flex 子項該加 min-width: 0 的都加了
  • 所有圖片都有尺寸
  • 滿版用 100svh 不是 100vh

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言