Stacking contexts and the top layer.
Raising z-index to 9999 feels like it should settle any argument about what is on top, and then the menu still disappears behind a header set to 10. The number was never global. Every z-index is a rank inside one stacking context, and an ancestor with a transform, an opacity below 1 or a filter quietly starts a new one. Once you can see that tree, layering bugs stop being guesswork, and you also know when to step out of it entirely: the top layer, where dialogs and popovers paint above everything without a z-index at all.
Builds on Positioning and containing blocks.
The idea.
z-index looks like a height above the page. It is really a rank among siblings, and the browser decides which siblings by building a tree of stacking contexts.
Slatework's board has a top bar that stays stuck to the top of the window, and every task card has a More button that opens a small menu. On a short laptop screen the placement code flips the menu upward when a card sits low on the screen. The first report from users: the top two items of the menu, Edit and Move to, are hidden under the top bar. The menu has z-index: 9999. The top bar has z-index: 10. Someone tries 99999, and nothing changes.
The browser never compares 9999 with 10. Painting happens per stacking context: a group of boxes that is layered as one unit inside its parent group. A box's z-index only orders it among the other members of its own group. To decide whether the menu is above the top bar, the browser walks up from both until it finds the group they share, and compares the two members of that group that contain them. Here, those members are the top bar itself and the card that holds the menu.
Why is the card a group of its own? Every card carries transform: translateY(0), so hovering can lift it by two pixels and the drag code can move it. Any transform other than none starts a new stacking context. The card has no z-index of its own, so in the root it sits at level 0, and 10 beats 0. The menu goes down with its card, whatever number it carries. The same rule hides the menu when it opens downward: the next card is also a level-0 context, it comes later in the document, so it paints over the whole of the first card, menu included.
The stacking tree behind the hidden menu
4 steps. Step through to see what one change does to these components.
The root context, flattened into paint order
- page background, 1 item, Page and board (no context)
- board and sidebar, 1 item, Page and board (no context)
- card A, 1 item, A card's stacking context, level 0
- menu 9999, 1 item, The More menu, inside card A
- card B, 1 item, A card's stacking context, level 0, later in the tree
- top bar, 1 item, Top bar (z-index 10), level 10
- empty, 1 item, Nothing here
- Page and board (no context)
- A card's stacking context
- The More menu
- Top bar (z-index 10)
- Nothing here
- card A
- level 0
- menu 9999
- inside card A
- card B
- level 0, later in the tree
- top bar
- level 10
As it starts. 3 steps follow.
How it works.
Three rules do all the work. A fixed order for painting inside one context, a list of properties that start a new context, and a walk up the tree to settle any pair. Then one exception that sits outside the tree.
The order inside one context
How one stacking context paints its members, first to last
| Step | What paints | In Slatework's root context |
|---|---|---|
| 1 | The background and borders of the box that forms the context | The page background on html |
| 2 | Child contexts with a negative z-index, most negative first | None. A z-index of -1 slides a box under its parent's in-flow content, but never under the parent context's own background |
| 3 | In-flow, non-positioned block boxes, in tree order | The sidebar and the board's columns |
| 4 | Non-positioned floats | None on the board |
| 5 | In-flow inline content: text, images, inline-blocks | Column headings and the sidebar's links |
| 6 | Positioned boxes with z-index auto or 0, and level-0 contexts such as a transformed or translucent box, all in tree order | Card A, then card B: both are transformed, so they land here in document order |
| 7 | Child contexts with a positive z-index, lowest first, ties in tree order | The top bar at 10 |
Two details in that table explain most surprises. First, a box that becomes a context through something other than z-index (a transform, opacity below 1, a filter) is painted at step 6, exactly as if it were positioned with z-index: 0. That is why the transformed cards end up below anything with a positive z-index, and above every plain block. Second, a context is painted as one unit: everything inside card A is drawn, in its own seven steps, at card A's slot in step 6. Nothing inside can be interleaved with card B or the top bar, however large its numbers.
z-index itself only works on some boxes: positioned ones (relative, absolute, fixed, sticky) and flex or grid items. On a plain static block it does nothing at all, which is the other classic reason a z-index seems to be ignored. Slatework's columns are flex containers, so each card is a flex item and can take a z-index without being positioned. The value auto means level 0 with no new context; any integer, 0 included, sets the level and starts a context.
What starts a new context
Properties that create a stacking context
| Property | Creates a context when | Where it sneaks in |
|---|---|---|
| html | Always: it is the root context | Every page |
| position: relative or absolute | z-index is not auto | A tooltip wrapper given z-index: 1 "just in case" |
| position: fixed or sticky | Always, even with z-index auto | Slatework's top bar, and every sticky table header |
| Flex or grid item | z-index is not auto | A card raised with z-index inside a flex column |
| opacity | Below 1 | A fade-in that stops at 0.98, or a disabled state drawn at 0.6 |
| transform, translate, scale, rotate | Any value other than none | Slatework's card lift, a drag library, a centring trick with translate(-50%, -50%) |
| filter, backdrop-filter | Any value other than none | A frosted-glass panel or a greyscale disabled state |
| clip-path, mask, mask-image, mask-border | Any value other than none | Rounded image masks |
| perspective | Any value other than none | 3D card flips |
| mix-blend-mode | Any value other than normal | Blend effects on hero images |
| isolation: isolate | Always; it does nothing else | The deliberate way to start a context |
| will-change | Names any property that would create one | will-change: transform added everywhere "for performance" |
| contain | layout, paint, or strict or content (which include them) | Containment added to speed up a long list |
| container-type | size or inline-size | A component made into a container-query container |
| Top layer, ::backdrop | Always: a modal dialog, a shown popover, a fullscreen element, and each one's backdrop | Every native overlay |
| Animations | An animation that fills forwards on one of the properties above | A fade that ends on opacity 0.99 and stays there |
How a Slatework card ends up a context, and how to make one on purpose
.card {
transform: translateY(0); /* hover lift and drag start here */
transition: transform 120ms ease;
}
.card:hover { transform: translateY(-2px); }
/* Also contexts, if someone adds them later: */
.card--archived { opacity: 0.6; }
.card--dragging { will-change: transform; }
.card__menu {
position: absolute;
z-index: 9999; /* ranks only inside .card */
}
/* The board starts one context of its own, so nothing inside it
can ever be compared with the app shell's header or toasts. */
.board {
isolation: isolate;
}
/* Inside the board, small local numbers are enough. */
.card__badge { position: absolute; z-index: 1; }
Settling who is on top
Any two boxes can be compared in four moves. It is the same procedure the browser follows, written out by hand, and it is worth saying aloud in an interview when a layering bug comes up.
- List the stacking contexts above each box, walking up the ancestors and keeping only those that create a context (most ancestors do not).
- Find the nearest context the two lists share. For the menu and the top bar, that is the root.
- Take the two members of that shared context that contain each box: the box itself if it is a direct member, otherwise its outermost context below the shared one. For the menu that member is card A; for the top bar, the bar itself.
- Compare those two by the paint steps: negative z first, then blocks, floats, inline content, then level 0 in tree order, then positive z. The winner paints over everything inside the loser.
The fourth move is why raising the inner number never helps: the inner number is not in the comparison. Only three things change the result. Make the inner box a member of the shared context (remove whatever created the context in between), raise the member that is being compared (the card, not the menu), or take the box out of the tree altogether.
The top layer
The top layer is a list the browser keeps next to the stacking tree. An element in it is painted after the whole root context, as if it were a sibling of html, so no ancestor's context can hold it down. Ancestors also stop clipping it with overflow or fading it with opacity, and its containing block becomes the viewport (the initial containing block if it is not fixed) rather than a transformed parent. Only the browser adds elements to the list; a page asks it to, through an API.
Inside the list, order is simply the order of arrival: the element added last paints on top. z-index on a top-layer element is ignored. Each entry gets a ::backdrop pseudo-element painted directly beneath it, the full size of the viewport, which is what dims the page behind a modal. The element stays where it was in the DOM, so selectors, inheritance and events work as before; only where it paints has changed.
The ways into the top layer
| Way in | Leaves when | Also does |
|---|---|---|
| dialog.showModal() | close(), a close request such as Esc, or a form with method="dialog" | Closes open auto popovers first and makes the rest of the document inert. The ::backdrop is the dim layer behind it |
| popover="auto" + showPopover() or a popovertarget button | A click outside, Esc, another auto popover opening (unless nested), or hidePopover() | Light dismiss and one-at-a-time behaviour come built in. Not modal: the page stays interactive |
| popover="manual" | Only hidePopover() or a toggle button | Never closes other popovers and ignores Esc; right for toasts that time out |
| element.requestFullscreen() | Exiting fullscreen | Video players and slide presenters |
| Customizable select | The picker closes | The open drop-down of a select styled with appearance: base-select |
Menu, toast and drawer arriving in the top layer
- Slatework code → MoreMenu: More clicked: popover="auto" opens
- MoreMenu → Top layer: add: [menu]
- Slatework code → Toaster: showPopover() "Link copied" (manual)
- Toaster → Top layer: add: [menu, toast 1]
- Slatework code → CardDrawer: Open card: showModal()
- Note over CardDrawer: first: hide open auto popovers
- Top layer → MoreMenu (reply): menu removed (manual toast stays)
- CardDrawer → Top layer: add with ::backdrop: [toast 1, drawer]
- Note over CardDrawer: toast 1 now dimmed under the backdrop
- Slatework code → Toaster: showPopover() "Moved to Review"
- Toaster → Top layer: add: [toast 1, drawer, toast 2]
- Note over Toaster: toast 2 paints above the drawer, but is inert
The top layer list, bottom to top
- menu, 1 item, More menu (auto popover)
- More menu (auto popover)
- Toast (manual popover)
- ::backdrop
- Card drawer (modal dialog)
As it starts. 4 steps follow.
The menu, the drawer and a toast, using the platform
<button class="card__more" popovertarget="menu-412"
style="anchor-name: --more-412">More</button>
<div id="menu-412" popover class="card__menu"
style="position-anchor: --more-412">
<button>Edit</button>
<button>Move to…</button>
<button>Copy link</button>
<button>Archive</button>
</div>
<style>
.card__menu {
inset: auto; /* drop the popover's centring defaults */
margin: 0;
position-area: bottom span-left; /* below the button, extending left */
position-try-fallbacks: flip-block; /* open upward when short of room */
}
</style>
<dialog id="card-drawer" aria-labelledby="drawer-title">…</dialog>
<div id="toast" popover="manual" role="status"></div>
<style>
#card-drawer::backdrop { background: rgb(20 24 32 / 0.45); }
</style>
<script>
const drawer = document.getElementById('card-drawer');
const toast = document.getElementById('toast');
function openCard() {
drawer.showModal(); // closes open auto popovers, page goes inert
}
let toastTimer;
function notify(text) {
toast.textContent = text;
toast.hidePopover(); // re-adding moves it to the top of the list
toast.showPopover();
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.hidePopover(), 5000);
}
</script>
A stacking context is not a compositor layer
The word layer means two different things in browser talk. A stacking context is part of the CSS model: it decides the order in which things are painted, and every browser must produce the same result. A compositor layer is an engine's private decision about which parts of the page to rasterise into separate bitmaps so the GPU can move them cheaply. The two often appear together, because will-change: transform both starts a stacking context and asks for a layer, which is why the topics get confused.
They do not map one to one. Slatework's archived cards at opacity 0.6 are stacking contexts that are usually painted straight into their parent's layer. A box can also get a layer of its own just because it overlaps one that already has a layer, without any change to its stacking. Fixing a layering bug is about contexts; deciding what deserves a layer is a memory and smoothness question, covered in Layers and compositing.
In practice.
Back to the Slatework board. The same bug drawn as boxes, three ways out of it, the scale the team settled on, and how the overlays behave once they live in the top layer.
The board, drawn as boxes in paint order
The boxes are listed in paint order. The top bar comes after card A's whole group, so the first rows of the menu (Edit, Move to) are under it.
Opens upward. Box layout “app.slatework.example/projects/atlas/board”: 9 shapes. View: 432 × 300 from -16, -28. Viewport: 400 × 260 at 0, 0. 1. “body” (frame) 400 × 260 at 0, 0 2. “nav.sidebar” (rect) 80 × 216 at 0, 44 gray in body 3. “main.board” (rect) 312 × 216 at 88, 44 in body 4. “card A” (stacking context) badge “transform” 150 × 64 at 100, 118 in body 5. “Fix login redirect” (rect) 104 × 22 at 108, 142 sand in card A 6. “More menu” (rect) badge “z-index: 9999” 110 × 108 at 160, 20 blue in card A 7. “card B” (stacking context) badge “transform” 150 × 64 at 100, 192 in body 8. “Draft release notes” (rect) 104 × 22 at 108, 216 sand in card B 9. “header.topbar” (rect) badge “sticky, z-index: 10” 400 × 44 at 0, 0 purple in body sticky at the viewport’s top (position: sticky)
- Sticky top bar1TopBar
- Transformed card2TaskCard
- Menu with z-index 99993MoreMenu
Three ways out, compared on Slatework's board
| Fix | What changes | What it costs |
|---|---|---|
| Remove the resting transform | Cards are transformed only while hovered or dragged, so most of the time the menu competes in the root with its 9999 | The bug comes back on hover, exactly when a user is likely to open the menu. Fragile: any future opacity or filter on the card restarts it |
| Raise the open card | The card, not the menu, gets a z-index above the top bar while its menu is open | Works today, but every new header, banner or sticky column must be checked against 30. Two open cards in a split view tie again |
| Move the menu to the top layer | popover="auto" on the menu: painted above everything, light dismiss and Esc for free, overflow no longer clips it | Placement is now relative to the viewport, so it needs anchor positioning (Baseline 2026) or a positioning script, and styles must not rely on the card as an ancestor for layout |
Slatework's z-index scale, for the root context only
| Token | Value | Used for | Note |
|---|---|---|---|
| --z-base | 0 | Normal content | The default; never written out |
| --z-raised | 1 | A card being dragged | Local: applies inside the board, which has isolation: isolate |
| --z-sticky | 100 | The top bar, sticky column headers | The highest number most screens ever need |
| --z-dropdown | 200 | Menus not yet moved to popover | Shrinks to nothing as the migration finishes |
| --z-overlay | 300 | The old hand-built drawer | Retired once the drawer became a dialog |
| --z-toast | 400 | The old toast stack | Retired once toasts became manual popovers |
| (top layer) | none | Menus, the card drawer, toasts | Ordered by arrival, not by number |
The scale as tokens, and the one rule that keeps it small
:root {
--z-raised: 1;
--z-sticky: 100;
--z-dropdown: 200; /* legacy, being removed */
}
.topbar { position: sticky; top: 0; z-index: var(--z-sticky); }
.board { isolation: isolate; } /* card numbers stay inside */
.card--dragging { z-index: var(--z-raised); }
/* Lint rule in review: a raw number in z-index outside this file
needs a comment saying which stacking context it competes in. */
Slatework's overlays after the move to the top layer
Every card is still transformed for the hover lift. Nothing about the cards had to change for the fix.
Board. Board: 5 items. Column “To do”: 1. Fix login redirect by Ines R. badges: Bug. 2. Draft release notes by Tomas K. Column “Doing”: 1. Migrate billing emails by Ana P. Column “Review”: 1. Audit colour contrast by Dev S. Column “Done”: 1. Ship the dark theme by Theo B.
- TaskCard (transformed)2TaskCard
- CardDrawer as a modal dialog4CardDrawer
Saying it in an interview
Where the system flows lean on this
| Flow | What gets layered | How it uses stacking or the top layer |
|---|---|---|
| pinterest/pin-closeup | A pin opened over the masonry grid | A modal dialog in the top layer with a backdrop; the grid stays mounted and inert behind it |
| netflix/title-details | The details modal over the home rows | showModal() and ::backdrop; the rows stay mounted and inert behind the dialog |
| airbnb/search-bar-and-filters | The Filters dialog and the panels under the search bar | Panels are popover=auto in the top layer, so light dismiss and one-open-at-a-time come free; Filters is a modal dialog |
| airbnb/date-range-picker | The two-month calendar under the date fields | A popover on desktop that has to clear everything on the listing page, and a full-screen sheet on phones |
| google-docs/share-and-permissions | The share dialog over the editor | A modal dialog; its people picker list is layered inside the dialog, not in the page |
| component-based-architecture/style-encapsulation-and-tokens | Popups that render away from their component | A portal moves the popup in the DOM, so wrapper selectors stop matching; a popover stays in place in the DOM and keeps inheriting |
Trade-offs.
Two choices every app makes, where overlays render and how z-index values are governed, and the traps that bring layering bugs back after they were fixed.
- Pro:Above everything with no z-index, whatever transforms or filters the page gains later
- Pro:Escapes overflow clipping on the card
- Pro:Light dismiss, Esc, one-open-at-a-time and the modal backdrop are built in
- Pro:Stays in the DOM where it was written, so inheritance and event bubbling are unchanged
- Con:Order is arrival order only; you cannot slot a toast between a dialog and its backdrop
- Con:Placement must come from anchor positioning (Baseline 2026) or script, since the card is no longer the containing block
- Con:Older browsers need a fallback path
Still a number war with every sticky header and fixed panel in the root; Moves the node in the DOM, so wrapper selectors and inherited context are lost; Dismissal, Esc and focus return must be rebuilt by hand
Clipped by any overflow on the card; Breaks again whenever an ancestor gains a transform, opacity or filter
- Pro:The global list stays at a handful of values that one team owns
- Pro:Components can use 1, 2, 3 internally without coordinating with anyone
- Pro:A raw number in review is a question, not a habit
- Con:Someone must know which boxes are contexts to place isolation correctly
- Con:A component that genuinely needs to escape its isolation has to move to the top layer
Numbers inside a context do not compete globally anyway, so the scale gives false confidence; Grows a new rung every time a team cannot see why its value loses
The path to 9999 and then 99999; the bug in this topic