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.

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 contextz-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.

ValueRa 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;
}

Insight then chốt: position: relative với z-index: auto không tự tạo stacking context. Cần z-index khác auto trên element positioned.


Sticky: Mạnh cho đến khi ancestor phá nó

position: sticky\{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

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 */
}

Checklist debug: Đi ngược chuỗi ancestor và kiểm tra overflow, height, display trê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)

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 */
}

Bug z-index số 1: 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. 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):

  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 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-mode blend với page content ngoài ý muốn
  • Giới hạn z-index mà không hack transform làm hỏng con position: fixed

Cẩn trọng: 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 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

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

Á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

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;
}

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;
}
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: 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)

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 đủ 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

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

Tóm tắt Mental Model

  1. z-index là local, không global — luôn hỏi “trong stacking context nào?”
  2. Nhiều property vô hại tạo context — opacity, transform, filter, flex child z-index
  3. sticky hỏng ở upstream — trace scroll container trước khi đổ lỗi CSS
  4. isolation: isolate là escape hatch có chủ đích cho layer trong component
  5. 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.