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.
Which box am I positioned relative to, and why is my dropdown still behind the header?
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.
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.
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.
z-index: 9999on the dropdown loses to a header withz-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: fixedelement inside a container withtransform,filter,backdrop-filter,perspective,contain: paintorwill-changeis not fixed to the viewport at all — that ancestor becomes its containing block, so it scrolls with the page. position: stickysilently does nothing when any ancestor between it and its scroll container hasoverflow: hidden,autoorscroll, 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.99for a fade, ortransform: translateZ(0)as a "performance trick", creates a stacking context and reorders the whole subtree against the rest of the page.
What is actually happening
In the browser, not in the framework.
position: relativeleaves 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: absoluteremoves the box from flow entirely: nothing reserves space for it, and its containing block is the padding box of the nearest ancestor with apositionother thanstatic(or the initial containing block if there is none).position: fixedpositions against the viewport — unless an ancestor hastransform,perspective,filter,backdrop-filter,contain: paint/layout, or awill-changenaming one of those. Any of those makes that ancestor the containing block, and "fixed" starts scrolling.position: stickyis 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-indexchildren, in-flow block boxes, floats, inline content,z-index: auto/0positioned children, then positivez-indexchildren. - 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-indexcan never escape it. - Stacking contexts are created by the root element, by a positioned element with a
z-indexother thanauto, and — importantly — by things that look purely visual:opacity < 1, anytransform,filter,backdrop-filter,mix-blend-modeother thannormal,isolation: isolate,contain: paint,will-changenaming such a property, andposition: 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: fixedandposition: stickyare 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: isolateis 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 block | Stacking context? | What silently breaks it |
|---|---|---|---|---|
static (initial) | Yes | n/a — offsets are ignored | No | z-index has no effect on it (unless it is a flex or grid item) |
relative | Yes — space is still reserved | Its own normal-flow position | Only with a z-index other than auto | Nothing; it is the safe one, and it is what makes descendants absolute-positionable |
absolute | No | Padding box of the nearest positioned ancestor | Only with a z-index other than auto | No positioned ancestor at all — it lands against the document |
fixed | No | The viewport | Yes, always | transform, filter, backdrop-filter, perspective, contain, will-change on any ancestor |
sticky | Yes — until the threshold | Its parent, offset within the nearest scrolling ancestor | Yes, always | No threshold set; an ancestor with overflow other than visible; a parent with no spare room |
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-indexother thanauto;position: fixedorsticky;opacitybelow 1; anytransform,filter,backdrop-filter,perspectiveorclip-path;mix-blend-modeother thannormal;isolation: isolate;contain: paintorcontain: layout;will-changenaming any of the above; a flex or grid item with az-indexother thanauto. - Does not create one:
overflow: hidden(it clips, which is a different thing people confuse with stacking);position: relativewithz-index: auto;z-indexon 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 noz-indexinvolved at all — which is the correct answer for modals, and increasingly for menus and tooltips.
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.
semantics <dialog> opened with showModal(), or role="dialog" with aria-modal="true" and an accessible name from aria-labelledby pointing at its heading.
| Escape | Closes the dialog and returns focus to the element that opened it — native with <dialog>, manual otherwise |
| Tab | Cycles within the dialog only; the background must not be reachable |
| Shift+Tab | Cycles backwards within the dialog, wrapping at the first focusable element |
- — 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 needsinerton 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.
- — 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-modaldialog 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
z-index raised repeatedly and the element stays behind | Dropdown paints under a header that has a much lower z-index | An ancestor established a stacking context; the dropdown's value only competes inside it | Find 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 animation | A position: fixed modal starts scrolling with the page | The transformed ancestor became the containing block for fixed descendants | Render 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 wrapper | Nothing happens at all — no error, no movement | No threshold set, or an ancestor has overflow other than visible, or the parent has no spare room | Set an inset, then check every ancestor's overflow up to the scroll container. |
| A correctly positioned dropdown inside a card | It is cut off at the card's edge | overflow: hidden on the card clips descendants regardless of positioning — clipping is not stacking | Render 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 navigation | Tab appears to focus nothing; the page scrolls to a blank strip | The browser scrolled the focused element into view, directly under the header | scroll-margin-block-start on focusable content, sized to the header (Focus Management). |
How to build it
Most important first.
- Fix
z-indexproblems 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-indexvalues, ideally as design tokens with names. Ad-hoc numbers are how a codebase acquiresz-index: 100000(Design Tokens). - Add
isolation: isolatedeliberately 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: stickyover a scroll listener that togglesposition: 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-starton 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
inerton 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-sizeand their own overflow handling. position: fixedand 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
- 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
transformadded for an animation, which breaks everyposition: fixeddescendant — including a modal that now scrolls with the page. overflow: hiddenon 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-changeleft in the stylesheet permanently. It creates a stacking context and often a layer, forever, for an animation that runs once (Cheap and Expensive Animation).
- A sticky header whose height changes after fonts load leaves every
scroll-marginvalue 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
resizeand on scroll, or use an anchoring API rather than a one-time measurement.
- 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).
- "Higher
z-indexwins." It wins only among siblings in the same stacking context. Across contexts the ancestors' order decides, and no descendant value can change it. - "
position: fixedis always relative to the viewport." Only if no ancestor has a transform, filter, perspective, containment orwill-changefor one of those. This is the most common cause of a broken modal. - "
z-indexrequiresposition." It applies to positioned elements *and* to flex and grid items, where it works without anypositionat all. - "
position: absoluteis 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
overflowyou did not know was there.
Measuring it, and what changes in the field
- 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,containor a positioned element with az-index. The first one you hit is your ceiling.
- On a phone, dynamic browser chrome changes the viewport that
fixedis 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-indexis a shared global namespace across every component and every third-party widget on the page. It only stays coherent if it is owned somewhere.
- 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: isolatemakes 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: stickyis 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.
- — Software Design — a
z-indexscale is a shared global namespace with no compiler enforcing it, and it decays exactly the way every other unowned global namespace does.