Positioning, Stacking and Overflow
Your dropdown is behind the header. You set z-index: 9999 and nothing happened. The reason is always one of two things - a stacking context you did not know you created, or an ancestor with overflow: hidden clipping it - and both are invisible in the CSS of the element that is wrong.
The five positioning values
.a { position: static; } /* the default. top/left do nothing. */
.b { position: relative; } /* offset from where it would have been */
.c { position: absolute; } /* removed from flow, placed in its containing block */
.d { position: fixed; } /* removed from flow, placed in the viewport */
.e { position: sticky; } /* in flow, until it reaches a threshold */
relative
The element stays in the flow - the space it occupied is still reserved - and is drawn offset from there.
Its real job is usually not the offset at all but to become a containing block for an absolutely positioned child:
.card { position: relative; }
.card .badge { position: absolute; top: .5rem; right: .5rem; }
absolute
Removed from the flow entirely; nothing reserves space for it. It is positioned relative to its nearest positioned ancestor - the nearest ancestor with a position other than static.
If there is none, it falls back to the initial containing block, which is roughly the page - and a badge that was meant to sit on a card ends up in the top-right of the document. That is nearly always the bug: a missing position: relative on the parent.
.overlay { position: absolute; inset: 0; } /* top/right/bottom/left all 0 */
fixed
Positioned relative to the viewport, so it does not move when the page scrolls.
With one large exception: if any ancestor has a transform, filter, backdrop-filter, perspective, will-change or contain: paint, that ancestor becomes the containing block instead, and fixed behaves like absolute within it. A fixed header inside a container with a transform on it scrolls away, and nothing in either rule suggests why.
sticky
In the flow, scrolling normally, until it reaches the offset you gave it - then it stops and stays there while its parent scrolls past.
.section-head { position: sticky; top: 0; }
Three things make sticky fail, and they account for essentially every report of "sticky does not work":
- No offset.
position: stickywith notop,bottom,leftorrightnever sticks. There is no threshold to reach. - An ancestor with
overflow: hidden,autoorscroll. The element sticks within that scroll container, which has usually already scrolled past. - The parent is not tall enough. Sticky works within the parent's box. If the parent is exactly the height of the sticky element, there is nowhere to stick.
Stacking, which is where z-index goes wrong
Without any z-index, elements paint in a defined order: backgrounds, then non-positioned blocks, then floats, then inline content, then positioned elements in source order. Later in the source paints on top.
z-index changes that - but only within a stacking context.
What creates a stacking context
A stacking context is a self-contained painting layer. Its children are painted entirely within it; one of them cannot be lifted out past a sibling of its parent, whatever its z-index is.
Common creators:
positionother thanstaticwith az-indexthat is notautoopacityless than 1transform,filter,backdrop-filter,perspectivewill-changenaming any of the aboveisolation: isolatemix-blend-modeother thannormal- a flex or grid item with a
z-index contain: paintorcontent
That list is the whole explanation for z-index: 9999 failing. Your dropdown has 9999 inside a card with opacity: .99; the card has no z-index and sits below the header; the dropdown cannot escape the card. 9999 is being compared against its siblings inside the card, where it already won.
Reading the problem
<header style="position: relative; z-index: 10">...</header>
<main style="transform: translateZ(0)"> <!-- creates a context -->
<div class="dropdown" style="z-index: 9999"> <!-- trapped in it -->
Open this example in the editor and change it. Nothing you type is saved.
main has no z-index, so it paints in source order - after the header, but as a single unit. Everything inside it, including the 9999, paints with it.
The fix is at the level of the context, not the element: give main a z-index, or remove the transform, or move the dropdown out of main - which is what a <dialog> or the top layer does for you.
Keeping it manageable
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
}
Named steps, spaced apart, all in one place. The point is not the numbers but that they are comparable - and that nobody has to invent one.
isolation: isolate is the deliberate version: it creates a stacking context with no other side effect, so a component's internal z-index values cannot leak out or be invaded.
.card { isolation: isolate; }
The top layer
<dialog id="d">...</dialog>
Open this example in the editor and change it. Nothing you type is saved.
d.showModal();
Open this example in the editor and change it. Nothing you type is saved.
A dialog opened with showModal() is painted in the top layer, above everything on the page, regardless of stacking contexts and z-index entirely. Popovers do the same. This is the genuine solution to the whole class of problem, and it is why modals should be dialogs rather than hand-built divs.
Overflow, and what it quietly does
.box {
overflow: visible; /* default - content spills */
overflow: hidden; /* clipped, no scrollbar */
overflow: auto; /* scrollbar only if needed */
overflow: clip; /* clipped, no scroll container */
overflow-x: hidden; overflow-y: auto; /* per axis */
}
Three consequences of hidden, auto and scroll that are not obvious:
- They create a block formatting context, which stops margins collapsing through.
- They create a scroll container, which is what breaks
position: stickyon descendants. - They clip absolutely positioned descendants, even ones positioned relative to something outside.
overflow: clip was added to give you the clipping without the scroll container, which is usually what "do not spill" actually means.
One asymmetry worth knowing: setting overflow-x: hidden and leaving overflow-y: visible does not work. The specification makes a visible value compute to auto when the other axis is not visible, so you get a vertical scrollbar you did not ask for. If you want horizontal clipping only, overflow-x: clip is the one that behaves.
What goes wrong
| Symptom | Cause |
|---|---|
| Badge appears at the top of the page | Parent has no position: relative |
z-index: 9999 does nothing |
Trapped in a stacking context |
| Fixed header scrolls away | An ancestor has a transform |
| Sticky element never sticks | No offset, or an ancestor scroll container |
| Dropdown clipped by its card | overflow: hidden on the card |
| Unwanted vertical scrollbar | overflow-x: hidden promoting the other axis |
| Modal behind the page content | A hand-built div instead of a <dialog> |
How to find it in a minute
When something is behind something else: select it in the inspector and walk up the ancestors, looking for opacity, transform, filter, will-change or a z-index. The first ancestor with any of those is the context your element is trapped in, and that ancestor is where the fix goes.
When something is clipped: walk up looking for overflow. Same method, same answer - the problem is never in the CSS of the element that looks wrong.