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.
The Bug Every Senior Has Hit {Bug mà mọi senior đều từng gặp}
You set z-index: 9999 on a dropdown {Bạn đặt z-index: 9999 cho dropdown}. It still renders under a sidebar with z-index: 2 {Nó vẫn render dưới sidebar có z-index: 2}. You add position: relative to the parent {Bạn thêm position: relative cho parent}. Still broken {Vẫn hỏng}. You inspect, tweak, add !important (please don’t) {Bạn inspect, chỉnh, thêm !important (đừng làm vậy)}, and eventually discover an ancestor with opacity: 0.99 or transform: translateZ(0) {Rồi phát hiện ancestor có opacity: 0.99 hoặc transform: translateZ(0)}.
That is not a browser bug {Đó không phải bug trình duyệt}. That is stacking context scoping — z-index only compares siblings inside the same context {Đó là phạm vi stacking context — z-index chỉ so sánh sibling trong cùng context}. This post maps the full model: position values, what creates a context, paint order, isolation: isolate, and the modern replacement for tooltip coordinate math — CSS Anchor Positioning {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}.
Live Demo {Demo trực tiếp}
The interactive demo below has two panels {Demo tương tác dưới có hai panel}: a stacking context explorer with three overlapping boxes, parent context toggles, and a rival sibling {một stacking context explorer với ba box chồng nhau, toggle context trên parent, và sibling đối thủ}; and an anchor positioning popover tied to a button with placement switches {và một popover anchor positioning gắn với button kèm chuyển vị trí}.
Open the full demo {Mở demo đầy đủ}: /tools/css-stacking-demo/.
Position: The Foundation Before z-index {Position: Nền tảng trước z-index}
z-index only applies to positioned elements and flex/grid items with explicit z-index {z-index chỉ áp dụng cho element positioned và flex/grid item có z-index explicit}. Understanding position first prevents half the confusion {Hiểu position trước sẽ tránh được nửa sự nhầm lẫn}.
| Value | Removed from flow? {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;
}
Key insight {Insight then chốt}:
position: relativewithz-index: autodoes not create a stacking context by itself {position: relativevớiz-index: autokhông tự tạo stacking context}. You needz-indexother thanautoon a positioned element {Cầnz-indexkhácautotrên element positioned}.
Sticky: Powerful Until an Ancestor Breaks It {Sticky: Mạnh cho đến khi ancestor phá nó}
position: sticky is \{position: sticky\} with a threshold {position: sticky là \{position: sticky\} kèm ngưỡng}:
.section-nav {
position: sticky;
top: 0;
z-index: 10;
}
The element behaves as relative until its scroll container would scroll it past top / bottom / inline offsets, then it “sticks” like fixed within that scrollport {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 đó}.
Why sticky silently fails {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 */
}
Debug checklist {Checklist debug}: Walk up the ancestor chain and check
overflow,height, anddisplayon each node {Đi ngược chuỗi ancestor và kiểm traoverflow,height,displaytrên từng node}. Sticky is not broken — your scroll container is just not the one you assumed {Sticky không hỏng — scroll container của bạn không phải cái bạn nghĩ}.
What Creates a Stacking Context {Điều gì tạo Stacking Context}
A stacking context is an isolated layer group {Stacking context là nhóm layer cô lập}. Children compete with each other; the group competes as one unit with siblings outside {Con cạnh tranh với nhau; nhóm cạnh tranh như một đơn vị với sibling bên ngoài}.
Definitive triggers (2025-2026) {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 */
}
The #1 z-index bug {Bug z-index số 1}: A child with
z-index: 9999inside a parent context at effective layer 1 cannot paint above a sibling of the parent atz-index: 2{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}. The child’s index is local, not global {Chỉ số của con là local, không phải global}.
Painting Order Inside a Context {Thứ tự paint trong Context}
Within a single stacking context, the browser paints in a defined order (simplified) {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 contexts nest like folders {Stacking context lồng như thư mục}: compare folder order first, then files inside the winning folder {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 — Surgical Context Creation {isolation: isolate — Tạo Context có chủ đích}
When you want a new stacking context without side effects like opacity or transform {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 cases {Use case}:
- Component libraries shipping overlays inside cards {Thư viện component ship overlay trong card}
- Preventing
mix-blend-modefrom blending with page content unintentionally {Ngănmix-blend-modeblend với page content ngoài ý muốn} - Scoping z-index without
transformhacks that breakposition: fixeddescendants {Giới hạn z-index mà không hacktransformlàm hỏng conposition: fixed}
Caution {Cẩn trọng}:
isolation: isolateon a wrapper around aposition: fixedmodal can change containing block behavior in some browsers when combined with transforms on other ancestors {isolation: isolatetrên wrapper quanh modalposition: fixedcó thể đổi containing block khi kết hợp transform trên ancestor khác}. Test portal patterns {Test pattern portal}.
Debugging z-index in Production {Debug z-index trên Production}
Strategy 1: Find the context boundary {Chiến lược 1: Tìm ranh giới context}
In DevTools, toggle \{opacity, transform, filter\} on suspected ancestors one at a time {Trong DevTools, bật/tắt \{opacity, transform, filter\} trên ancestor nghi ngờ từng cái một}. When the bug disappears, you found the context creator {Khi bug biến mất, bạn đã tìm thấy thứ tạo context}.
Strategy 2: Flatten intentionally with a portal {Chiến lược 2: Làm phẳng bằng portal}
Render overlays at document.body (or a top-level #overlay-root with high z-index) to escape nested contexts {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>
Strategy 3: Document layer tokens {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 |
Apply tokens at the same context level — usually portal root or layout shell {Áp token cùng cấp context — thường là portal root hoặc layout shell}.
CSS Anchor Positioning: Layout Without getBoundingClientRect {CSS Anchor Positioning: Layout không cần getBoundingClientRect}
Before anchor positioning, tying a tooltip to a button required JavaScript measuring rects, scroll listeners, and resize observers {Trước anchor positioning, gắn tooltip với button cần JS đo rect, scroll listener, và resize observer}. CSS now exposes anchors as first-class layout inputs {CSS giờ expose anchor như input layout first-class}.
Core properties {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;
}
Fine-grained offsets with anchor() {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, and Anchor Positioning Together {Top Layer, Popover, và Anchor Positioning cùng nhau}
The popover attribute (Popover API) promotes elements to the top layer — above ordinary stacking contexts without extreme z-index wars {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>
Combine with anchor positioning for placement {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 {Progressive enhancement}: Feature-detect with
CSS.supports('anchor-name', '--x')and fall back to absolute positioning or a positioning library {Feature-detect bằngCSS.supports('anchor-name', '--x')và fallback sang absolute positioning hoặc thư viện positioning}.
Browser Support and Rollout (2025-2026) {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 is production-viable in 2026 for evergreen browsers with a JS/CSS fallback for older embedded WebViews {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;
}
}
Decision Guide {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 |
Mental Model Summary {Tóm tắt Mental Model}
z-indexis local, not global — always ask “inside which stacking context?” {z-indexlà local, không global — luôn hỏi “trong stacking context nào?”}- Many innocent properties create contexts — opacity, transform, filter, flex child z-index {Nhiều property vô hại tạo context — opacity, transform, filter, flex child z-index}
stickyfails upstream — trace scroll containers before blaming CSS {stickyhỏng ở upstream — trace scroll container trước khi đổ lỗi CSS}isolation: isolateis the intentional escape hatch for component-scoped layers {isolation: isolatelà escape hatch có chủ đích cho layer trong component}- Anchor positioning + popover replace most coordinate math for anchored UI in modern browsers {Anchor positioning + popover thay hầu hết phép tính toạ độ cho UI neo anchor trên browser hiện đại}
The demo at the top lets you feel the z-index trap — toggle opacity: 0.99 on the parent and watch green at z-index: 20 lose to a rival at z-index: 5 {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}. That single interaction is worth more than another Stack Overflow answer {Một tương tác đó đáng giá hơn cả câu trả lời Stack Overflow nữa}.