LayoutGENERALBROWSER-SPECIFIC

Positioning and Stacking Contexts

Taking a box out of flow: which ancestor it is positioned against, and why z-index: 9999 still loses to a header with z-index: 1.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Which box am I positioned relative to, and why is my dropdown still behind the header?

The user intent

Someone is building a dropdown menu inside a card, on a page with a sticky header. They want the menu to appear over everything when it opens, and the header to stay put while the page scrolls.

The obvious build

position: absolute puts it where I say, and z-index decides what is on top. If it is behind something, raise the z-index until it is not.

Why it breaks

z-index: 9999 on the dropdown loses to a header with z-index: 1, because the dropdown's ancestor established a stacking context and the whole subtree is painted at that ancestor's level. The number is compared against siblings, not against the page.

How it breaks in a real browser
  • z-index: 9999 on the dropdown loses to a header with z-index: 1, because the dropdown's ancestor established a stacking context and the whole subtree is painted at that ancestor's level. The number is compared against siblings, not against the page.
  • A position: fixed element inside a container with transform, filter, backdrop-filter, perspective, contain: paint or will-change is not fixed to the viewport at all — that ancestor becomes its containing block, so it scrolls with the page.
  • position: sticky silently does nothing when any ancestor between it and its scroll container has overflow: hidden, auto or scroll, because it sticks within *that* ancestor and there is no room to move.
  • An absolutely positioned element with no positioned ancestor is placed against the initial containing block, so a menu meant to sit inside a card appears at the top-left of the document.
  • Adding opacity: 0.99 for a fade, or transform: translateZ(0) as a "performance trick", creates a stacking context and reorders the whole subtree against the rest of the page.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • position: relative leaves the box in flow — its space is still reserved — and offsets it visually. It also makes the box a containing block for absolutely positioned descendants, which is its more important job.
  • position: absolute removes the box from flow entirely: nothing reserves space for it, and its containing block is the padding box of the nearest ancestor with a position other than static (or the initial containing block if there is none).
  • position: fixed positions against the viewport — unless an ancestor has transform, perspective, filter, backdrop-filter, contain: paint/layout, or a will-change naming one of those. Any of those makes that ancestor the containing block, and "fixed" starts scrolling.
  • position: sticky is flow-relative until a threshold is crossed, then it is offset within its nearest scrolling ancestor. It needs a threshold (top, bottom, inset-block-start…) and room to move inside its parent; without either it is inert with no error.
  • Painting order is a tree walk, not a global sort. Within a stacking context the browser paints, in order: backgrounds and borders, negative z-index children, in-flow block boxes, floats, inline content, z-index: auto/0 positioned children, then positive z-index children.
  • A stacking context is a self-contained painting subtree. Once an element establishes one, all its descendants are painted within it, and the whole group is placed as a single unit in its parent context. A descendant's z-index can never escape it.
  • Stacking contexts are created by the root element, by a positioned element with a z-index other than auto, and — importantly — by things that look purely visual: opacity < 1, any transform, filter, backdrop-filter, mix-blend-mode other than normal, isolation: isolate, contain: paint, will-change naming such a property, and position: fixed/sticky.

What this makes the browser do

And which of it is avoidable.

  • Out-of-flow boxes still cost layout: absolute and fixed boxes are laid out against their containing block after in-flow layout, so they are extra work, not skipped work.
  • position: fixed and position: sticky are handled with compositor involvement in modern engines, so a sticky header can keep its position during a scroll that the main thread is too busy to service — right up until something forces a main-thread update (Compositing Layers).
  • Each stacking context is a paint-ordering boundary, which lets the browser reason about a subtree independently. That is genuinely useful for invalidation, and it is why isolation: isolate is cheap.
  • Properties that create stacking contexts often also promote a layer. Promoting deliberately can help an animation; promoting accidentally across a hundred elements costs memory and rasterisation for nothing (Layer Explosion).

Which box am I positioned against?

Every positioning bug that is not a stacking bug is a containing-block bug. The containing block is the rectangle that top, inset-inline-start, width: 50% and the rest are resolved against, and each position value picks it differently.

The row that catches everyone is fixed. Its containing block is the viewport *by default*, and a long list of ordinary visual properties on any ancestor takes that away. A parent with transform: translateY(0) — added for an animation, in a different file, by a different person — is enough.

`position`In flow?Containing blockStacking context?What silently breaks it
static (initial)Yesn/a — offsets are ignoredNoz-index has no effect on it (unless it is a flex or grid item)
relativeYes — space is still reservedIts own normal-flow positionOnly with a z-index other than autoNothing; it is the safe one, and it is what makes descendants absolute-positionable
absoluteNoPadding box of the nearest positioned ancestorOnly with a z-index other than autoNo positioned ancestor at all — it lands against the document
fixedNoThe viewportYes, alwaystransform, filter, backdrop-filter, perspective, contain, will-change on any ancestor
stickyYes — until the thresholdIts parent, offset within the nearest scrolling ancestorYes, alwaysNo threshold set; an ancestor with overflow other than visible; a parent with no spare room
A sticky header that does not cover focus
1.page-header {
2 position: sticky;
3 inset-block-start: 0; /* the threshold — without it, sticky does nothing */
4 z-index: 10;
5}
6
7/* the browser scrolls a focused element into view. tell it to leave room. */
8:is(a, button, input, select, textarea, [tabindex]) {
9 scroll-margin-block-start: var(--header-block-size, 4rem);
10}
11
12/* an ancestor with any of these makes a fixed descendant scroll with the page */
13.card:hover { transform: translateY(-2px); } /* <- breaks fixed inside .card */

The scroll-margin-block-start line is the accessibility fix: without it, tabbing to a link near the top of the viewport scrolls it exactly under the header, and the user sees focus disappear.

Why `z-index: 9999` loses

z-index is not a global sort key. Painting is a depth-first walk of a tree of stacking contexts, and a z-index is only ever compared with its siblings inside the context it belongs to. Once an ancestor establishes a context, everything below it is painted as one unit at that ancestor's position in the parent context.

So the dropdown with z-index: 9999 inside a card with opacity: 0.98 is painted inside the card's context. The card is painted at whatever the card's own level is. The header, a sibling of the card with z-index: 1, is painted after it. No number inside the card can change that — only leaving the card can.

  • Creates a stacking context: the root element; a positioned element with z-index other than auto; position: fixed or sticky; opacity below 1; any transform, filter, backdrop-filter, perspective or clip-path; mix-blend-mode other than normal; isolation: isolate; contain: paint or contain: layout; will-change naming any of the above; a flex or grid item with a z-index other than auto.
  • Does not create one: overflow: hidden (it clips, which is a different thing people confuse with stacking); position: relative with z-index: auto; z-index on a static, non-flex, non-grid element (it is ignored entirely).
  • The debugging move: select the element, walk up the ancestor chain, and stop at the first one carrying any property in the first list. That element is the ceiling, and your fix belongs at or above it.
  • The escape hatch: the browser's top layer. <dialog>.showModal() and the Popover API paint above every stacking context in the document, with no z-index involved at all — which is the correct answer for modals, and increasingly for menus and tooltips.
Two stacking contexts, one losing argument
sibling, painted later9999 is compared HERE onlythe whole subtree moves as onez-index 1, but in the ROOT contextRoot stacking context (html).main — static, no context.header — position: sticky, z-index: 1 → own context.card — opacity: .98 → NEW stacking context.dropdown — z-index: 9999 (inside the card)Paint order: .main → .card (with everything inside it) → .header
UserLLMAgentToolDataDecisionHumanGuardrail

Overlays: the pattern the stacking rules exist to serve

Nearly all of this matters because of overlays, and an overlay is not finished when it is on top. It is finished when it is on top, not clipped, focus is inside it, the background is inert, Escape closes it and focus returns where it came from.

The platform now does the hard parts. <dialog> with showModal() gives the top layer, background inertness, Escape handling and an accessible dialog role for free. Reimplementing that with z-index and a click handler reliably reproduces three of the five bugs below.

accessibility specModal dialog rendered above the pageOverlay, done properly

semantics <dialog> opened with showModal(), or role="dialog" with aria-modal="true" and an accessible name from aria-labelledby pointing at its heading.

EscapeCloses the dialog and returns focus to the element that opened it — native with <dialog>, manual otherwise
TabCycles within the dialog only; the background must not be reachable
Shift+TabCycles backwards within the dialog, wrapping at the first focusable element
Focus
  • Move focus into the dialog when it opens — to the first focusable control, or to the dialog itself if there is nothing sensible.
  • Trap focus inside while it is open: showModal() does this via the top layer and background inertness; a hand-rolled dialog needs inert on the background or an explicit trap.
  • Return focus to the triggering element on close, including when the close came from Escape or a background click.
Announces
  • The dialog's accessible name is announced on open, so it must name what the dialog is for rather than repeating the trigger label.
  • Content behind an aria-modal dialog is removed from the accessibility tree, so anything the user still needs — a status message, an error — must be inside the dialog.

usually broken by Building the overlay out of position: fixed and a high z-index alone. It looks identical and leaves the background tabbable, Escape dead, focus wherever the trigger left it, and the whole thing trapped inside the first ancestor that established a stacking context.

The four positioning bugs, and what each actually is
TriggerSymptomCauseResponse
z-index raised repeatedly and the element stays behindDropdown paints under a header that has a much lower z-indexAn ancestor established a stacking context; the dropdown's value only competes inside itFind the ancestor and fix it there, or render the overlay in the top layer where stacking does not apply.
A transform added to a parent for an animationA position: fixed modal starts scrolling with the pageThe transformed ancestor became the containing block for fixed descendantsRender the modal outside that subtree, or animate a property that does not create a containing block (Cheap and Expensive Animation).
position: sticky on a header inside a wrapperNothing happens at all — no error, no movementNo threshold set, or an ancestor has overflow other than visible, or the parent has no spare roomSet an inset, then check every ancestor's overflow up to the scroll container.
A correctly positioned dropdown inside a cardIt is cut off at the card's edgeoverflow: hidden on the card clips descendants regardless of positioning — clipping is not stackingRender the menu outside the clipping ancestor, or use an anchor-positioned popover in the top layer (Normal Flow, Overflow and Margin Collapsing).
Sticky header plus keyboard navigationTab appears to focus nothing; the page scrolls to a blank stripThe browser scrolled the focused element into view, directly under the headerscroll-margin-block-start on focusable content, sized to the header (Focus Management).

How to build it

Most important first.

  • Fix z-index problems by finding the stacking context, not by raising the number. The question is never "is 9999 enough" — it is "which ancestor is this subtree painted inside".
  • Render overlays — modals, dropdowns, tooltips, toasts — outside the component subtree, at the top level of the document, or use the top layer via <dialog> and the Popover API, which escape stacking contexts entirely by design.
  • Keep a small, documented set of z-index values, ideally as design tokens with names. Ad-hoc numbers are how a codebase acquires z-index: 100000 (Design Tokens).
  • Add isolation: isolate deliberately to a component root that must not leak its stacking into the page. It creates a stacking context with no other side effects — no opacity change, no layer promotion.
  • Prefer position: sticky over a scroll listener that toggles position: fixed. Sticky is declarative, handled off the main thread where possible, and does not jump when the main thread is busy (Scroll and Input Latency).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A sticky or fixed header will cover a focused element after the browser scrolls it into view. scroll-margin-block-start on focusable content — set to the header height — tells the browser to leave that much room, and it is the single most valuable line in a sticky-header stylesheet (Focus Management).
  • Painting order is not focus order. An overlay painted on top of the page is still in the middle of the tab sequence unless focus is explicitly moved into it and constrained while it is open.
  • Content underneath a modal must be inert as well as covered. Without inert on the background — or the top-layer behaviour <dialog>.showModal() provides — a keyboard or screen-reader user tabs straight behind the overlay into content they cannot see.
  • A fixed overlay sized in viewport units can become unusable at 400% zoom: it covers most of the screen and, if it is not scrollable, its own actions become unreachable. Overlays need max-block-size and their own overflow handling.
  • position: fixed and the on-screen keyboard interact badly on mobile: a bottom-fixed toolbar can sit under the keyboard, hiding the submit button for exactly the user who is typing.

What can go wrong

Failure modes
  • The escalating z-index: each new overlay outbids the last, and eventually the numbers stop meaning anything because they are being compared inside different contexts anyway.
  • A transform added for an animation, which breaks every position: fixed descendant — including a modal that now scrolls with the page.
  • overflow: hidden on an ancestor clipping a dropdown that was correctly positioned. Positioning does not escape clipping; only leaving the subtree does (Normal Flow, Overflow and Margin Collapsing).
  • A sticky header that covers the element the user just focused, so keyboard navigation appears to scroll to a blank area.
  • will-change left in the stylesheet permanently. It creates a stacking context and often a layer, forever, for an animation that runs once (Cheap and Expensive Animation).
What can arrive out of order
  • A sticky header whose height changes after fonts load leaves every scroll-margin value that was derived from the old height wrong, so focused elements land underneath it.
  • An overlay positioned on open, against a page whose layout is still settling — late images, late fonts, a late-arriving banner — is anchored to coordinates that stop being true a frame later. Reposition on resize and on scroll, or use an anchoring API rather than a one-time measurement.
Security
  • A positioned, transparent element over a control is the mechanism of clickjacking. The user aims at what they see; hit testing follows the topmost box at that point (Clickjacking and Framing).
  • The same is true in reverse: a control positioned over third-party embedded content can capture interaction intended for it, which is why frame-busting headers exist server-side rather than in CSS.
  • Stacking is not visibility control. An element painted behind another is still in the DOM, still focusable, and still readable by script and by assistive technology.
  • Overlays rendered at the document root escape the component's own containment, so a component that injects unsanitized HTML into a portal escapes any clipping that used to limit its damage (Sanitization and Trusted HTML).
Misreads
  • "Higher z-index wins." It wins only among siblings in the same stacking context. Across contexts the ancestors' order decides, and no descendant value can change it.
  • "position: fixed is always relative to the viewport." Only if no ancestor has a transform, filter, perspective, containment or will-change for one of those. This is the most common cause of a broken modal.
  • "z-index requires position." It applies to positioned elements *and* to flex and grid items, where it works without any position at all.
  • "position: absolute is relative to the parent." It is relative to the nearest positioned ancestor, which may be several levels up, or the initial containing block if there is none.
  • "Sticky is broken in this container." Sticky is doing exactly what it is specified to do: sticking within an ancestor that has no room. The bug is the overflow you did not know was there.

Measuring it, and what changes in the field

How you would see this
  • Chromium DevTools shows the containing block and the stacking context for a selected element in the Elements panel's Layout and Computed views; the 3D View panel renders the stacking-context tree, which makes "which ancestor am I trapped inside" visually obvious.
  • The Layers panel shows what was promoted to its own compositing layer and why, including layers created by side effect (Layer Explosion).
  • To find the culprit by hand: walk up the ancestors and look for transform, opacity, filter, will-change, contain or a positioned element with a z-index. The first one you hit is your ceiling.
Slow device, slow network, large data, old tab
  • On a phone, dynamic browser chrome changes the viewport that fixed is positioned against as the user scrolls, so a bottom-fixed bar moves in ways it never does on a desktop (The Viewport and Device Pixels).
  • On a slow device, a scroll handler that repositions an element runs behind the scroll, producing a header that lags visibly. Sticky positioning does not, because it is not waiting on the main thread (Scroll and Input Latency).
  • In a design-system context, z-index is a shared global namespace across every component and every third-party widget on the page. It only stays coherent if it is owned somewhere.
What this costs
  • Rendering overlays at the document root fixes stacking and clipping, and separates the overlay from the component that owns it — you now have to manage focus, positioning and unmounting across a boundary the framework does not draw for you.
  • isolation: isolate makes a component predictable and prevents its children from ever painting above a sibling component, which is occasionally exactly what a tooltip needed to do.
  • position: sticky is smoother and less controllable than a scroll handler: no callback, no hysteresis, no way to change behaviour mid-scroll without adding one back.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALContaining-block rules, painting order and the list of stacking-context-creating properties are specified in CSS Positioned Layout and CSS Color/Compositing, and current Blink, Gecko and WebKit agree on all of them.
  • BROWSER-SPECIFICOnly the tooling differs, and it differs a lot: Chromium DevTools shows the containing block, the stacking context and a 3D stacking view, while Firefox marks sticky and fixed elements with badges but exposes no stacking-context tree, and Safari has neither — so the same debugging session takes very different shapes per browser.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — a z-index scale is a shared global namespace with no compiler enforcing it, and it decays exactly the way every other unowned global namespace does.