Here is a bug that many front-end teams run into sooner or later.
You click a menu in the header. It opens, but only its top edge shows. The rest is hidden behind the hero banner below the header.
So you do what everyone does. You give the dropdown z-index: 9999. Nothing changes. You try 99999. Still nothing.
The number was never the problem. The problem is where the number lives.
Think of z-index as a version number
MDN has a helpful way to picture this:
To figure out the rendering order of stacked elements along the z-axis, think of each index value as a "version number" of sorts, where child elements represent minor version numbers underneath their parent's major version number.
Here is what that means in practice.
Say the header is version 0 and the hero banner is version 1. The dropdown sits inside the header. So its full number is not 9999. It is 0.9999.
And 0.9999 is still smaller than 1. The hero wins.
The box that holds those "minor" numbers is called a stacking context. MDN says:
"Each stacking context is self-contained. After an element's contents are stacked, the entire element is considered as a single unit in the stacking order of its parent stacking context."
In short: a child can never climb above its stacking context. It can only move up or down inside it.
Bug 1: The sticky header
Here is that page, cut down to the parts that matter.
<header class="site-header"> <nav> <div class="nav-item"> Products <div class="dropdown">...</div> </div> </nav> </header> <section class="hero">...</section> .site-header { background: white; position: sticky; top: 0; } .nav-item { position: relative; } .dropdown { position: absolute; top: 100%; left: 0; z-index: 9999; } .hero { position: relative; z-index: 1; /* added earlier for a background effect */ } Two things are going on.
First, position: sticky makes the header a stacking context on its own. MDN's list of triggers includes "Element with a position value fixed or sticky." So the dropdown's 9999 only counts inside the header.
Second, the header itself has no z-index. In the parent's stack, it sits at the same level as z-index: 0. The hero has z-index: 1. So the whole header, dropdown and all, paints under the hero.
We checked this in Chrome 156 with document.elementFromPoint(). A point inside the lower part of the dropdown returned the hero, not the dropdown.
The fix
Raise the header, not the dropdown.
.site-header { background: white; position: sticky; top: 0; z-index: 10; /* the fix */ } Now the header is version 10 and the hero is version 1. The dropdown comes along with its parent. The same elementFromPoint() check now returned the dropdown.
The dropdown can go back to a small number too. z-index: 1 is plenty inside the header.
Bug 2: The fade-in that never ends
This one is sneakier, because the page looks the same before and after the bug.
.panel { animation: fade-in 0.3s forwards; } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } The panel holds a menu with position: absolute and z-index: 1. Below the panel is a position: relative box with no z-index. The menu opens and hides behind that box.
Why? opacity below 1 makes a stacking context. That part is well known. But the animation ends at opacity: 1, so it should be fine, right?
Not quite. MDN's list also includes an "Element that has had stacking context-creating properties (such as opacity) animated using @keyframes, with animation-fill-mode set to forwards."
So forwards keeps the stacking context alive after the animation ends. The panel stays a closed box, and the positioned box after it paints on top.
The fix
Here the end value of the animation, opacity: 1, is the same as the normal value. So forwards does nothing useful. Drop it.
.panel { animation: fade-in 0.3s; } In our Chrome 156 test, this one change let the menu show above the box below it.
If you really do need forwards, give the panel a z-index and position: relative so you control where its box sits.
Other things that quietly create a stacking context
These are the ones we run into most. The full list is on MDN.
- a relatively or absolutely positioned box that also sets a number for
z-index - any
position: fixedorposition: stickybox, even when it has noz-index -
opacitybelow 1, such as0.99 - any non-
nonetransform,filter,backdrop-filter,clip-pathormask isolation: isolate-
will-changethat names one of those properties - a child of a flex or grid layout that sets a number for
z-index, even withoutposition
The last one surprises people. A flex or grid child with z-index: 1 can trap a menu just like a transform can.
Stop the next one before it starts
The fixes above work. But on a big team the same bug keeps coming back. Someone adds z-index: 1 to a section, and a menu somewhere else breaks.
Two habits help a lot.
1. Keep a small z-index scale
Put every layer your app uses in one place.
:root { --z-base: 0; --z-raised: 1; --z-header: 100; --z-overlay: 200; --z-modal: 300; --z-toast: 400; } .site-header { z-index: var(--z-header); } .hero { z-index: var(--z-base); } Now nobody types 9999. They pick a named layer. And when two layers clash, you fix it in one file.
This is common in design systems, and it works best for top-level page parts. Inside a component, small local numbers like 1 and 2 are fine, because they stay inside that component's stacking context anyway.
2. Seal off sections that use z-index inside
If a section needs z-index for its own inner layout, wrap it with isolation: isolate.
.hero { isolation: isolate; } isolation: isolate is on MDN's list of stacking context triggers. It makes the section a closed box without moving it. The z-index values inside it can no longer fight with the header or with menus from other parts of the page.
When a menu has to beat everything
Some things, like a modal, must appear above the whole page no matter what. For those, the top layer is a better tool than any z-index.
Elements opened with the Popover API or with dialog.showModal() go into the browser's top layer. That layer sits above the normal page, so no ancestor can trap them. See the MDN links below for how each one works.
We tested both bugs in Chrome 156 only, so please check your layout in Firefox and Safari too.
Quick checklist when z-index "does nothing"
- Find the element that is hidden and the element that covers it.
- Walk up from the hidden one. Look for the first ancestor that makes a stacking context (use the list above).
- Compare that ancestor with the covering element, not the dropdown.
- Raise the ancestor, or remove the property that made the context.
Further reading
- Stacking context (MDN): https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context
- The
z-indexproperty (MDN): https://developer.mozilla.org/en-US/docs/Web/CSS/z-index - The
isolationproperty (MDN): https://developer.mozilla.org/en-US/docs/Web/CSS/isolation - The
animation-fill-modeproperty (MDN): https://developer.mozilla.org/en-US/docs/Web/CSS/animation-fill-mode - Popover API (MDN): https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
- CSS 2 spec, Elaborate description of stacking contexts: https://www.w3.org/TR/CSS2/zindex.html
PIXEL RECRUITING LLC helps teams hire UI and design systems engineers, the people who keep layering rules like these tidy across a whole product. We are a direct staffing partner for Mastercard in O'Fallon, MO. Learn how we work at pixelrecruiting.com.