jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 scopingz-index only compares siblings inside the same context {Đó là phạm vi stacking contextz-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}.

ValueRemoved from flow? {Ra khỏi flow?}Containing blockTypical use
static (default)NoNormalDefault document flow
relativeNo (space preserved)Self (for abs descendants)Offset without leaving flow; stacking hook
absoluteYesNearest positioned ancestorOverlays, tooltips (legacy)
fixedYesViewport (usually)Sticky headers, modals
stickyHybridScrollport of nearest scroll ancestorSection 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: relative with z-index: auto does not create a stacking context by itself {position: relative với z-index: auto không tự tạo stacking context}. You need z-index other than auto on a positioned element {Cần z-index khác auto trê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\{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}

CauseWhat 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 + offsetNever engages; stays relative
Flex/grid child without align-self: startStretching can prevent stickiness in column flex
display: table-* ancestorsSticky 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, and display on each node {Đi ngược chuỗi ancestor và kiểm tra overflow, height, display trê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)}

TriggerExample
Root element (<html>)Always the outermost context
position + z-indexautoposition: relative; z-index: 1
position: fixed / stickyCreates context even at z-index: auto
Flex/grid item with z-indexautoChild of display: flex
opacity < 1opacity: 0.99 — yes, really
transformnonetranslateZ(0), scale(1), rotate(0deg)
filternoneEven blur(0)
clip-pathnoneSVG or basic shapes
mask / mask-imagenone
mix-blend-modenormal
isolation: isolateExplicit opt-in
will-change hinting transform/opacity/filterCan promote early
contain: layout style paintPaint containment creates context
container-type / container-nameContainer query roots
view-transition-namenoneView Transitions API
perspectivenone on parent3D 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: 9999 inside a parent context at effective layer 1 cannot paint above a sibling of the parent at z-index: 2 {Con có z-index: 9999 trong 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)}:

  1. Background and borders of the context root
  2. Negative z-index descendants
  3. In-flow, non-positioned blocks
  4. Floats
  5. In-flow inline content
  6. position: relative/absolute at z-index: auto
  7. Positive z-index descendants (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-mode from blending with page content unintentionally {Ngăn mix-blend-mode blend với page content ngoài ý muốn}
  • Scoping z-index without transform hacks that break position: fixed descendants {Giới hạn z-index mà không hack transform làm hỏng con position: fixed}

Caution {Cẩn trọng}: isolation: isolate on a wrapper around a position: fixed modal can change containing block behavior in some browsers when combined with transforms on other ancestors {isolation: isolate trên wrapper quanh modal position: fixed có 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}

TokenSuggested rangeLayer
--z-base0Page content
--z-dropdown100Menus, popovers
--z-sticky200Sticky headers
--z-modal300Dialogs
--z-toast400Notifications

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}

PropertyRole
anchor-nameNames an element as an anchor (e.g. --trigger)
position-anchorLinks positioned element to a named anchor
anchor()Offset functions: anchor(top), anchor(center), anchor-size(width)
position-areaShorthand placement: top, bottom, left, right, block-start, etc.
position-try-fallbacksOrdered fallback placements when anchor overflows
position-visibilityHide 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;
}
ApproachEscapes nested z-index?Placement logic
z-index onlyNo — trapped in contextManual tokens
Portal to bodyYesJS or CSS
popover + top layerYes (top layer)JS or CSS anchor
Anchor positioningRelative to anchor, not z-indexDeclarative 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ằng CSS.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)}

FeatureChromiumFirefoxSafari
Stacking context (classic)UniversalUniversalUniversal
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}

ProblemReach for
Dropdown trapped under sidebarFind context creator; portal or raise parent layer, not child
Sticky header not stickingFix overflow / scroll ancestor
Component needs local z-index scopeisolation: isolate on component root
Tooltip/menu placementanchor-name + position-anchor + position-try-fallbacks
Modal must beat everythingpopover or top-level portal + layer token
Third-party widget z-index wariframe or shadow DOM boundary — contexts don’t cross

Mental Model Summary {Tóm tắt Mental Model}

  1. z-index is local, not global — always ask “inside which stacking context?” {z-index là local, không global — luôn hỏi “trong stacking context nào?”}
  2. 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}
  3. sticky fails upstream — trace scroll containers before blaming CSS {sticky hỏng ở upstream — trace scroll container trước khi đổ lỗi CSS}
  4. isolation: isolate is the intentional escape hatch for component-scoped layers {isolation: isolate là escape hatch có chủ đích cho layer trong component}
  5. 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}.