CSS Stacking Context 教學: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 / absolute 且 z-index 不是 auto |
| 固定定位 | position: fixed、sticky |
| 視覺效果 | opacity < 1、filter 非 none |
| 變形 | 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...。


