CSS Stacking Contexts and Anchor Positioning: Why z-index 9999 Still Loses
Stacking contexts, paint order, sticky pitfalls, and CSS anchor positioning — the senior guide to z-index scoping and popover layout without JavaScript.
Bug mà mọi senior đều từng gặp
Bạn đặt z-index: 9999 cho dropdown. Nó vẫn render dưới sidebar có z-index: 2. Bạn thêm position: relative cho parent. Vẫn hỏng. Bạn inspect, chỉnh, thêm !important (đừng làm vậy), Rồi phát hiện ancestor có opacity: 0.99 hoặc transform: translateZ(0).
Đó không phải bug trình duyệt. Đó là phạm vi stacking context — z-index chỉ so sánh sibling trong cùng context. Bài viết này vẽ toàn bộ model: giá trị position, điều gì tạo context, thứ tự paint, isolation: isolate, và cách thay thế hiện đại cho phép tính toạ độ tooltip — CSS Anchor Positioning.
Demo trực tiếp
Demo tương tác dưới có hai panel: một stacking context explorer với ba box chồng nhau, toggle context trên parent, và sibling đối thủ; và một popover anchor positioning gắn với button kèm chuyển vị trí.
Mở demo đầy đủ:
Position: Nền tảng trước z-index
z-index chỉ áp dụng cho element positioned và flex/grid item có z-index explicit. Hiểu position trước sẽ tránh được nửa sự nhầm lẫn.
| Value | Ra khỏi flow? | Containing block | Typical use |
|---|---|---|---|
static (default) | No | Normal | Default document flow |
relative | No (space preserved) | Self (for abs descendants) | Offset without leaving flow; stacking hook |
absolute | Yes | Nearest positioned ancestor | Overlays, tooltips (legacy) |
fixed | Yes | Viewport (usually) | Sticky headers, modals |
sticky | Hybrid | Scrollport of nearest scroll ancestor | Section headers, table columns |
.card {
position: relative; /* establishes containing block for .badge */
}
.badge {
position: absolute;
top: -0.5rem;
right: -0.5rem;
z-index: 1;
}
Insight then chốt:
position: relativevớiz-index: autokhông tự tạo stacking context. Cầnz-indexkhácautotrên element positioned.
Sticky: Mạnh cho đến khi ancestor phá nó
position: sticky là \{position: sticky\} kèm ngưỡng:
.section-nav {
position: sticky;
top: 0;
z-index: 10;
}
Element behave như relative cho đến khi scroll container sắp cuộn nó qua offset top / bottom / inline, rồi “dính” như fixed trong scrollport đó.
Vì sao sticky im lặng thất bại
| Cause | What happens |
|---|---|
Any ancestor with overflow: hidden, auto, or scroll (except visible on both axes) | Sticky sticks to that ancestor’s box, not the viewport — often looks “broken” |
| No room to stick — parent shorter than sticky element + offset | Never engages; stays relative |
Flex/grid child without align-self: start | Stretching can prevent stickiness in column flex |
display: table-* ancestors | Sticky unsupported in table layout contexts |
/* ❌ Common production bug */
.app-shell {
overflow: hidden; /* sticky header inside main will NOT stick to viewport */
}
.main {
overflow-y: auto;
}
.page-header {
position: sticky;
top: 0; /* sticks to .main scrollport only — may be what you want, often isn't */
}
Checklist debug: Đi ngược chuỗi ancestor và kiểm tra
overflow,height,displaytrên từng node. Sticky không hỏng — scroll container của bạn không phải cái bạn nghĩ.
Điều gì tạo Stacking Context
Stacking context là nhóm layer cô lập. Con cạnh tranh với nhau; nhóm cạnh tranh như một đơn vị với sibling bên ngoài.
Trigger xác định (2025-2026)
| Trigger | Example |
|---|---|
Root element (<html>) | Always the outermost context |
position + z-index ≠ auto | position: relative; z-index: 1 |
position: fixed / sticky | Creates context even at z-index: auto |
Flex/grid item with z-index ≠ auto | Child of display: flex |
opacity < 1 | opacity: 0.99 — yes, really |
transform ≠ none | translateZ(0), scale(1), rotate(0deg) |
filter ≠ none | Even blur(0) |
clip-path ≠ none | SVG or basic shapes |
mask / mask-image ≠ none | |
mix-blend-mode ≠ normal | |
isolation: isolate | Explicit opt-in |
will-change hinting transform/opacity/filter | Can promote early |
contain: layout style paint | Paint containment creates context |
container-type / container-name | Container query roots |
view-transition-name ≠ none | View Transitions API |
perspective ≠ none on parent | 3D rendering contexts |
.modal-backdrop {
/* Creates stacking context — children z-index trapped here */
opacity: 0.6;
}
.modal-panel {
z-index: 9999; /* only wins inside .modal-backdrop's context */
}
Bug z-index số 1: Con có
z-index: 9999trong parent context ở layer hiệu dụng 1 không thể paint trên sibling của parent ởz-index: 2. Chỉ số của con là local, không phải global.
Thứ tự paint trong Context
Trong một stacking context, browser paint theo thứ tự xác định (rút gọn):
- Background and borders of the context root
- Negative
z-indexdescendants - In-flow, non-positioned blocks
- Floats
- In-flow inline content
position: relative/absoluteatz-index: auto- Positive
z-indexdescendants (low → high)
.layer-a { position: relative; z-index: 1; }
.layer-b { position: relative; z-index: 2; } /* paints on top of .layer-a */
Stacking context lồng như thư mục: so sánh thứ tự folder trước, rồi file trong folder thắng.
Root context
├── Context A (z-index: 1) ← entire subtree capped here
│ ├── child (z-index: 9999)
│ └── child (z-index: 2)
└── Context B (z-index: 5) ← beats A no matter what children say
└── child (z-index: 1)
isolation: isolate — Tạo Context có chủ đích
Khi bạn muốn stacking context mới mà không kèm side effect như opacity hay transform:
.dropdown-root {
isolation: isolate;
/* Creates context — dropdown z-index scoped intentionally */
}
.dropdown-menu {
position: absolute;
z-index: 1; /* competes only inside .dropdown-root */
}
Use case:
- Thư viện component ship overlay trong card
- Ngăn
mix-blend-modeblend với page content ngoài ý muốn - Giới hạn z-index mà không hack
transformlàm hỏng conposition: fixed
Cẩn trọng:
isolation: isolatetrên wrapper quanh modalposition: fixedcó thể đổi containing block khi kết hợp transform trên ancestor khác. Test pattern portal.
Debug z-index trên Production
Chiến lược 1: Tìm ranh giới context
Trong DevTools, bật/tắt \{opacity, transform, filter\} trên ancestor nghi ngờ từng cái một. Khi bug biến mất, bạn đã tìm thấy thứ tạo context.
Chiến lược 2: Làm phẳng bằng portal
Render overlay tại document.body (hoặc #overlay-root top-level với z-index cao) để thoát context lồng nhau:
<body>
<div id="app"><!-- nested contexts --></div>
<div id="overlay-root" style="position:relative;z-index:1000"></div>
</body>
Chiến lược 3: Token hoá layer
| Token | Suggested range | Layer |
|---|---|---|
--z-base | 0 | Page content |
--z-dropdown | 100 | Menus, popovers |
--z-sticky | 200 | Sticky headers |
--z-modal | 300 | Dialogs |
--z-toast | 400 | Notifications |
Áp token cùng cấp context — thường là portal root hoặc layout shell.
CSS Anchor Positioning: Layout không cần getBoundingClientRect
Trước anchor positioning, gắn tooltip với button cần JS đo rect, scroll listener, và resize observer. CSS giờ expose anchor như input layout first-class.
Thuộc tính cốt lõi
| Property | Role |
|---|---|
anchor-name | Names an element as an anchor (e.g. --trigger) |
position-anchor | Links positioned element to a named anchor |
anchor() | Offset functions: anchor(top), anchor(center), anchor-size(width) |
position-area | Shorthand placement: top, bottom, left, right, block-start, etc. |
position-try-fallbacks | Ordered fallback placements when anchor overflows |
position-visibility | Hide when anchor scrolls off-screen |
.menu-trigger {
anchor-name: --menu-trigger;
}
.menu-panel {
position: fixed;
position-anchor: --menu-trigger;
position-area: bottom;
margin-top: 8px;
position-try-fallbacks: flip-block, flip-inline;
}
Offset chi tiết với anchor():
.tooltip {
position: fixed;
position-anchor: --tip-anchor;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
position-try-fallbacks: --above, --below;
}
@position-try --above {
top: anchor(top);
translate: -50% -100%;
}
Top Layer, Popover, và Anchor Positioning cùng nhau
popover attribute (Popover API) đưa element lên top layer — trên stacking context thường mà không cần z-index cực đoan.
<button popovertarget="help-tip" type="button">Help</button>
<div id="help-tip" popover>Native top-layer popover</div>
Kết hợp anchor positioning để đặt vị trí:
[popover] {
position: fixed;
position-anchor: --help-trigger;
position-area: bottom;
border: none;
padding: 0;
background: transparent;
}
.help-trigger {
anchor-name: --help-trigger;
}
| Approach | Escapes nested z-index? | Placement logic |
|---|---|---|
z-index only | No — trapped in context | Manual tokens |
Portal to body | Yes | JS or CSS |
popover + top layer | Yes (top layer) | JS or CSS anchor |
| Anchor positioning | Relative to anchor, not z-index | Declarative CSS |
Progressive enhancement: Feature-detect bằng
CSS.supports('anchor-name', '--x')và fallback sang absolute positioning hoặc thư viện positioning.
Hỗ trợ trình duyệt và Rollout (2025-2026)
| Feature | Chromium | Firefox | Safari |
|---|---|---|---|
| Stacking context (classic) | Universal | Universal | Universal |
isolation: isolate | ✅ | ✅ | ✅ |
anchor-name / position-anchor | ✅ 125+ | ✅ 131+ | ✅ 18+ |
position-area | ✅ | ✅ (partial aliases) | ✅ |
position-try-fallbacks | ✅ | ✅ | ✅ |
| Popover API + top layer | ✅ | ✅ | ✅ 17+ |
Anchor positioning đủ production năm 2026 trên evergreen browser kèm fallback JS/CSS cho WebView cũ.
@supports (anchor-name: --x) {
.popover {
position-anchor: --x;
position-area: bottom;
}
}
@supports not (anchor-name: --x) {
.popover {
/* fallback: centered below trigger via absolute + JS toggle class */
position: absolute;
top: 100%;
left: 50%;
translate: -50% 8px;
}
}
Hướng dẫn quyết định
| Problem | Reach for |
|---|---|
| Dropdown trapped under sidebar | Find context creator; portal or raise parent layer, not child |
| Sticky header not sticking | Fix overflow / scroll ancestor |
| Component needs local z-index scope | isolation: isolate on component root |
| Tooltip/menu placement | anchor-name + position-anchor + position-try-fallbacks |
| Modal must beat everything | popover or top-level portal + layer token |
| Third-party widget z-index war | iframe or shadow DOM boundary — contexts don’t cross |
Tóm tắt Mental Model
z-indexlà local, không global — luôn hỏi “trong stacking context nào?”- Nhiều property vô hại tạo context — opacity, transform, filter, flex child z-index
stickyhỏng ở upstream — trace scroll container trước khi đổ lỗi CSSisolation: isolatelà escape hatch có chủ đích cho layer trong component- Anchor positioning + popover thay hầu hết phép tính toạ độ cho UI neo anchor trên browser hiện đại
Demo ở trên cho bạn cảm cái bẫy z-index — bật opacity: 0.99 trên parent và xem green ở z-index: 20 thua rival ở z-index: 5. Một tương tác đó đáng giá hơn cả câu trả lời Stack Overflow nữa.