protonvpn

CSS Stacking Context 教學:z-index 為什麼不聽話?

z-index 再大,也出不了這個世界

你一定遇過這些情況:
z-index: 9999 還是沒用、dropdown 被切掉、modal 被 header 壓住。
這篇文章會用一個「投影片世界」的比喻,就像上面這張圖,馬上就能了解問題的根源。

核心概念:什麼是 Stacking Context(堆疊上下文)

在多數人的直覺裡,z-index 像是一個「數字越大就越上面」的全域排序系統。
但實際上,CSS 的層級運作方式完全不是這樣

比較正確的理解方式是:

z-index 只在同一個 stacking context 裡有意義。

投影片世界的比喻

可以把整個畫面想成很多層透明投影片疊在一起。

  • 預設情況下,所有元素都在同一個「大投影片」裡
  • 一旦某個元素建立了 stacking context
    它就變成一疊「自己的小投影片」

這個小投影片裡面的子元素:

  • 彼此之間可以用 z-index 排名
  • 但永遠無法跳出這一疊,跟外面的元素比較

也就是說:

  • 子元素的 z-index: 9999
  • 永遠贏不了父層 stacking context 在外部世界的順位

記住這句就好:
z-index 不是全域排名,而是「區域內排序」。

z-index 會失效的真正原因

在實務中,z-index 不聽話通常不是因為你寫錯數字,而是因為你忽略了下面兩件事。

z-index 不是隨便就能用

預設情況下,元素是 position: static,而這種狀態下:

.badge {
  z-index: 10; /* 不會生效 */
}

原因是: static 元素根本不參與層級計算。

正確寫法至少要這樣:

.badge {
  position: relative; /* 或 absolute / fixed / sticky */
  z-index: 10;
}

可以把它想成: 你必須先「加入層級系統」,才有資格比大小。

父層一旦建立世界,子層就被封印

當父元素建立 stacking context 時,會發生一件非常關鍵的事:

子元素的 z-index,只在這個父世界裡有效。

這也是為什麼很多人會遇到:

  • 子元素數字設很大
  • 卻還是被外面的元素壓住

因為瀏覽器實際比較的是:

父層 stacking context vs 父層 stacking context

而不是你以為的子元素。

哪些情況會建立新的 Stacking Context

你不需要記完整規格,但下面這些是實務中最常踩到的。

常見觸發條件整理

類型會建立 Stacking Context 的情況
定位position: relative / absolutez-index 不是 auto
固定定位position: fixedsticky
視覺效果opacity < 1filternone
變形transform(即使只是 translate(0)
最安全做法isolation: isolate

這裡面最容易被忽略的是 transform,因為它看起來只是動畫用,但實際上會直接改變層級行為。

isolation: isolate 是救命用的

如果你希望某個區塊:

  • 內部層級自己處理
  • 不影響外部,也不被外部影響

請直接用:

.section {
  isolation: isolate;
}

這個屬性唯一的目的就是建立 stacking context, 不會順便影響定位、動畫或 layout,非常適合拿來當「防火牆」。

為什麼 z-index: 9999 還是輸

這是一個最典型、也最容易誤判的例子。

結構範例

<div class="header">
  zindex 1
  <div class="dropdown">
    zindex 9999
    我是 dropdown
  </div>
</div>

<div class="sidebar">
  zindex 2
  我是 sidebar
</div>
.header {
  position: relative;
  z-index: 1;
}

.dropdown {
  position: absolute;
  z-index: 9999;
}

.sidebar {
  position: relative;
  z-index: 2;
}

實際發生了什麼事

你以為 dropdown 會是最上面,實際上不是。

瀏覽器不是在比:

  • dropdown (9999)
  • sidebar (2)

而是在比:

  • .header (1)
  • .sidebar (2)

因為 .dropdown 被鎖在 .header 的世界裡。

結果就是:

整個 header(包含裡面的 dropdown) 都會被 sidebar 壓在下面

這也是為什麼調整 dropdown z-index 永遠沒用。

三個最常見、也最痛的實戰坑

overflow: hidden 把浮層切掉

這種情況常見於 card、layout container。

.card {
  overflow: hidden;
}

即使你的 dropdown 或 tooltip z-index 再高, 只要超出這個容器,就會被裁掉。

常見解法有兩種:

  • 把浮層移到不受 overflow 限制的父層
  • 直接用 portal,掛到 body

「Portal」是一種技術手法,讓浮層元素能直接渲染到 body 或其他節點,跳脫原本的 DOM 階層,常用於各種前端框架。 例如:

transform 讓層級突然亂掉

很多動畫會這樣寫:

.panel {
  transform: translateY(0);
}

但這行其實已經:

  • 建立新的 stacking context
  • 改變 fixed / absolute 的參考行為

實務建議是:

  • 不要在 layout 最外層用 transform
  • 動畫盡量包在最內層元素

fixed 不再相對視窗

如果某個祖先元素有:

  • transform
  • filter
  • perspective

那麼:

.modal {
  position: fixed;
}

會變成「相對那個祖先」,而不是瀏覽器視窗。

這也是為什麼 modal 明明是 fixed,卻會跟著捲動。

實用的 Debug 思考流程

當你發現層級怪怪的,可以照這個順序檢查。

  • 這個元素有沒有 position?
  • 最近的 stacking context 是誰?
  • 有沒有祖先用了 transform / opacity / filter?
  • 壓住它的元素,父層是誰?

在 Chrome DevTools 裡:

  • Elements 面板往上看 computed styles
  • Layers 面板可以直接看到層級分組

專案等級的 z-index 管理方式

如果沒有規範,最後一定會出現:

z-index: 9999999;

建議一開始就定義層級語意。

:root {
  --z-base: 0;
  --z-header: 100;
  --z-dropdown: 300;
  --z-overlay: 900;
  --z-modal: 1000;
  --z-toast: 1100;
}

使用時只用語意,不用數字。

.modal {
  position: fixed;
  z-index: var(--z-modal);
}

最穩定的 Tooltip / Modal 解法

如果你真的不想再跟 stacking context 糾纏, 最穩定的方式只有一種:

把浮層直接掛在 body 底下。

<button id="btn">Hover</button>
<div id="tip" class="tip">Safe tooltip</div>
.tip {
  position: fixed;
  z-index: var(--z-toast);
  opacity: 0;
  pointer-events: none;
}
const btn = document.querySelector('#btn');
const tip = document.querySelector('#tip');

btn.addEventListener('mouseenter', () => {
  const rect = btn.getBoundingClientRect();
  tip.style.left = `${rect.left + rect.width / 2}px`;
  tip.style.top = `${rect.bottom + 8}px`;
  tip.classList.add('show');
});

這也是大多數 UI library 採用的做法。

總結

  • z-index 不是全域排序
  • stacking context 才是真正的邊界
  • 父層層級決定一切
  • 浮層想省事,直接走 portal

只要你開始用「世界」來思考層級問題, z-index 就再也不會無止盡的 999999...。