Formatting contexts and CSS positioningStacking contexts and the top layer

100%

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.

Intermediate31 minUpdated 2 Oct 2026

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

The stacking tree behind the hidden menu. The numbered component cards that follow describe each part.
The stacking tree behind the hidden menuComponents: 1. TopBar (The app header: position: sticky top: 0 z-index: 10. Sticky alone makes it a stacking context in the root.), 2. TaskCard (One card on the board. A flex item in its column, with transform: translateY(0) for the hover lift and for dragging, which makes it a stacking context.), 3. MoreMenu (The card's actions: Edit, Move to, Copy link, Archive. Was an absolutely positioned div with z-index 9999 inside the card becomes a popover=auto element in the top layer.).

Root stacking contexthtml

Card Btransform: translateY(0) · level 0

Card B title and labels

2Card Atransform: translateY(0) · level 0

Card A title and labels
in-flow blocks and text

3MoreMenu
position: absolute
z-index: 9999

main.board
no position
no z-index
not a context

1TopBar
position: sticky
z-index: 10

4 steps. Step through to see what one change does to these components.

The root context, flattened into paint order

Root context
  1. page background, 1 item, Page and board (no context)
  2. board and sidebar, 1 item, Page and board (no context)
  3. card A, 1 item, A card's stacking context, level 0
  4. menu 9999, 1 item, The More menu, inside card A
  5. card B, 1 item, A card's stacking context, level 0, later in the tree
  6. top bar, 1 item, Top bar (z-index 10), level 10
Top layer
  1. 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
Start

As it starts. 3 steps follow.

Each segment is one member of a context, painted left (first) to right (last); a context's members paint inside its own span. Step through the shipped layout and two fixes to see where the menu lands.
Was this section helpful?

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

StepWhat paintsIn Slatework's root context
1The background and borders of the box that forms the contextThe page background on html
2Child contexts with a negative z-index, most negative firstNone. A z-index of -1 slides a box under its parent's in-flow content, but never under the parent context's own background
3In-flow, non-positioned block boxes, in tree orderThe sidebar and the board's columns
4Non-positioned floatsNone on the board
5In-flow inline content: text, images, inline-blocksColumn headings and the sidebar's links
6Positioned boxes with z-index auto or 0, and level-0 contexts such as a transformed or translucent box, all in tree orderCard A, then card B: both are transformed, so they land here in document order
7Child contexts with a positive z-index, lowest first, ties in tree orderThe 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

PropertyCreates a context whenWhere it sneaks in
htmlAlways: it is the root contextEvery page
position: relative or absolutez-index is not autoA tooltip wrapper given z-index: 1 "just in case"
position: fixed or stickyAlways, even with z-index autoSlatework's top bar, and every sticky table header
Flex or grid itemz-index is not autoA card raised with z-index inside a flex column
opacityBelow 1A fade-in that stops at 0.98, or a disabled state drawn at 0.6
transform, translate, scale, rotateAny value other than noneSlatework's card lift, a drag library, a centring trick with translate(-50%, -50%)
filter, backdrop-filterAny value other than noneA frosted-glass panel or a greyscale disabled state
clip-path, mask, mask-image, mask-borderAny value other than noneRounded image masks
perspectiveAny value other than none3D card flips
mix-blend-modeAny value other than normalBlend effects on hero images
isolation: isolateAlways; it does nothing elseThe deliberate way to start a context
will-changeNames any property that would create onewill-change: transform added everywhere "for performance"
containlayout, paint, or strict or content (which include them)Containment added to speed up a long list
container-typesize or inline-sizeA component made into a container-query container
Top layer, ::backdropAlways: a modal dialog, a shown popover, a fullscreen element, and each one's backdropEvery native overlay
AnimationsAn animation that fills forwards on one of the properties aboveA 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.

  1. List the stacking contexts above each box, walking up the ancestors and keeping only those that create a context (most ancestors do not).
  2. Find the nearest context the two lists share. For the menu and the top bar, that is the root.
  3. 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.
  4. 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 inLeaves whenAlso 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 buttonA 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 buttonNever closes other popovers and ignores Esc; right for toasts that time out
element.requestFullscreen()Exiting fullscreenVideo players and slide presenters
Customizable selectThe picker closesThe open drop-down of a select styled with appearance: base-select

Menu, toast and drawer arriving in the top layer

Menu, toast and drawer arriving in the top layer, as an ordered list of steps:
Menu, toast and drawer arriving in the top layer12 steps between Slatework code, MoreMenu, Toaster, CardDrawer, Top layer. The steps are listed as text after the diagram.Top layerCardDrawerToasterMoreMenuSlatework codefirst: hide open auto popoverstoast 1 now dimmed under the backdroptoast 2 paints above the drawer, but is inertMore clicked: popover="auto" opens1add: [menu]2showPopover() "Link copied" (manual)3add: [menu, toast 1]4Open card: showModal()5menu removed (manual toast stays)6add with ::backdrop: [toast 1, drawer]7showPopover() "Moved to Review"8add: [toast 1, drawer, toast 2]9
  1. Slatework code → MoreMenu: More clicked: popover="auto" opens
  2. MoreMenu → Top layer: add: [menu]
  3. Slatework code → Toaster: showPopover() "Link copied" (manual)
  4. Toaster → Top layer: add: [menu, toast 1]
  5. Slatework code → CardDrawer: Open card: showModal()
  6. Note over CardDrawer: first: hide open auto popovers
  7. Top layer → MoreMenu (reply): menu removed (manual toast stays)
  8. CardDrawer → Top layer: add with ::backdrop: [toast 1, drawer]
  9. Note over CardDrawer: toast 1 now dimmed under the backdrop
  10. Slatework code → Toaster: showPopover() "Moved to Review"
  11. Toaster → Top layer: add: [toast 1, drawer, toast 2]
  12. Note over Toaster: toast 2 paints above the drawer, but is inert

The top layer list, bottom to top

Top layer
  1. menu, 1 item, More menu (auto popover)
  • More menu (auto popover)
  • Toast (manual popover)
  • ::backdrop
  • Card drawer (modal dialog)
Start

As it starts. 4 steps follow.

The same four moments as the sequence above. Each segment is one entry, painted left (first) to right (last). Every entry has a ::backdrop immediately under it; the popovers' backdrops are transparent by default, so only the dialog's is drawn.

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.

Was this section helpful?

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)

  1. Sticky top bar1TopBar
  2. Transformed card2TaskCard
  3. Menu with z-index 99993MoreMenu

Three ways out, compared on Slatework's board

FixWhat changesWhat it costs
Remove the resting transformCards are transformed only while hovered or dragged, so most of the time the menu competes in the root with its 9999The 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 cardThe card, not the menu, gets a z-index above the top bar while its menu is openWorks 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 layerpopover="auto" on the menu: painted above everything, light dismiss and Esc for free, overflow no longer clips itPlacement 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

TokenValueUsed forNote
--z-base0Normal contentThe default; never written out
--z-raised1A card being draggedLocal: applies inside the board, which has isolation: isolate
--z-sticky100The top bar, sticky column headersThe highest number most screens ever need
--z-dropdown200Menus not yet moved to popoverShrinks to nothing as the migration finishes
--z-overlay300The old hand-built drawerRetired once the drawer became a dialog
--z-toast400The old toast stackRetired once toasts became manual popovers
(top layer)noneMenus, the card drawer, toastsOrdered 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.

  1. TaskCard (transformed)2TaskCard
  2. CardDrawer as a modal dialog4CardDrawer

Saying it in an interview

Q1
BugYour card's dropdown renders behind the sticky header even with z-index 9999. What is going on and what would you do?
z-index only ranks a box inside its stacking context. Something between the menu and the root starts a context: here a transform on the card. At the root the comparison is the card at level 0 against the header at 10, so the header wins for everything in the card. I would move the menu into the top layer with the popover attribute, which also escapes overflow clipping on the card, and place it with anchor positioning or a small positioning helper. If I had to keep it in place, I would raise the card, not the menu, and give that value a name in our scale.
Q2
DesignHow do you stop z-index values from drifting as more teams add overlays?
Two rules. A short scale of named values that only applies to the app shell's root context (sticky, dropdown and so on), and isolation: isolate on large components such as the board, so their internal numbers never compete with the shell. Then real overlays (menus, dialogs, toasts) go in the top layer, where order is arrival order and no number is involved. A review rule asks every raw z-index to say which context it competes in.
Q3
Follow-upWhy not render the menu in a portal at the end of body with a large z-index?
A portal works and was the standard answer before popover: as a direct child of body it competes in the root context and escapes the card's clipping. But it still needs a number above every sticky header and drawer, it loses the card's CSS ancestry, and I have to rebuild dismissal, Esc and one-open-at-a-time. The top layer gives me those behaviours and needs no number; the portal remains the fallback where popover cannot be used.
Q4
Edge caseA modal is open and your app shows a toast. Where does it appear, and can the user act on it?
If the toast is a popover shown after the dialog, it paints above the dialog, because the top layer is ordered by arrival. It is outside the modal, though, so it is inert: no clicking Undo, no focus. Feedback for something done inside the dialog belongs inside the dialog. The focus side of that is the keyboard-and-focus topic.

Where the system flows lean on this

FlowWhat gets layeredHow it uses stacking or the top layer
pinterest/pin-closeupA pin opened over the masonry gridA modal dialog in the top layer with a backdrop; the grid stays mounted and inert behind it
netflix/title-detailsThe details modal over the home rowsshowModal() and ::backdrop; the rows stay mounted and inert behind the dialog
airbnb/search-bar-and-filtersThe Filters dialog and the panels under the search barPanels 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-pickerThe two-month calendar under the date fieldsA popover on desktop that has to clear everything on the listing page, and a full-screen sheet on phones
google-docs/share-and-permissionsThe share dialog over the editorA modal dialog; its people picker list is layered inside the dialog, not in the page
component-based-architecture/style-encapsulation-and-tokensPopups that render away from their componentA portal moves the popup in the DOM, so wrapper selectors stop matching; a popover stays in place in the DOM and keeps inheriting
Was this section helpful?

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.

01
Where Slatework's menus, drawer and toasts render
Chosen:The top layer (popover for menus and toasts, dialog for the drawer)
  • 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
Downside we accept:
  • 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
Ruled out:A portal to the end of body with a z-index from the scale

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

Ruled out:In place inside the card, with the card raised while open

Clipped by any overflow on the card; Breaks again whenever an ancestor gains a transform, opacity or filter

02
How z-index values are governed across teams
Chosen:A short named scale for the root context, plus isolation inside big components
  • 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
Downside we accept:
  • 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
Ruled out:One global scale for every z-index in the app

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

Ruled out:No scale, local numbers chosen by each team

The path to 9999 and then 99999; the bug in this topic

Traps that bring the bug back

will-change everywhere
Adding will-change: transform to every card for smoother dragging makes every card a context permanently, and asks for a layer each too. Set it when a drag starts and remove it after.
The fade that stops short
A menu that fades in to opacity 0.99, or an animation that fills forwards on a context-creating property, leaves a context behind after it ends. Fade to exactly 1, or animate inside the top layer where it does not matter.
The shell that transforms
A page-transition transform on the app shell makes the whole app one level-0 context and makes the shell the containing block of every fixed descendant; the positioning topic shows the toast that scrolls away because of it. Transition a wrapper that holds no fixed or overlay content.
Negative z-index under a context
z-index: -1 on a decoration whose parent is not a context sends it below the parent's background, often out of sight behind the page. Make the parent a context (isolation: isolate) and the decoration lands between the parent's background and its content, which is usually what was meant.
Sticky makes a context too
Every sticky header is a context even with z-index auto, so a dropdown inside a sticky toolbar ranks only inside that toolbar. Opening it over the page needs the toolbar's level, or the top layer.
Expecting z-index in the top layer
z-index on a dialog or popover is ignored, and so is any attempt to put a page element over it. If something must appear above an open modal, it has to enter the top layer after the modal, or live inside it.
This topic
Paint order inside a stacking context and what creates one
Settling which of two boxes is on top
The top layer, its order and ::backdrop
A z-index scale and isolation
Elsewhere
Clipping, flipping and containing blocksPositioning and containing blocks
Focus, inert and modal semanticsKeyboard and focus
Which boxes get GPU layersLayers and compositing
Portal and popup component APIsComposition patterns
Token pipelines and themingDesign tokens and theming
Was this section helpful?
Related
The box model and normal flow
Read next