The Day the Popup Hid the Menu
A sweet shop is getting its website ready for Diwali. It has a sticky header that stays at the top while you scroll, a dropdown menu for the list of sweets, and a bright “Festival offer” popup in the corner. On launch day, customers complain: the dropdown opens behind the big banner, and when they scroll, the product cards slide over the header.
The developer tries z-index: 9999 on the dropdown. Nothing changes. This lesson explains why. You will learn how the browser decides which element is painted on top, when z-index works and when it does not, what a stacking context is, and how to keep your layers organised with a simple scale.
You met z-index briefly in the CSS Positioning lesson. Now we go deeper.
The Default Stacking Order
A web page is flat, but elements can overlap, for example with negative margins or position: absolute. Imagine the page as a pile of sheets of paper on a table. The z-axis is the direction coming out of the screen towards you. Elements nearer to you on the z-axis cover the ones behind them.
When you have not set any z-index, the browser paints in this order, from the back to the front:
- The background and borders of the parent.
- Normal (non-positioned) boxes, in HTML order.
- Positioned boxes (
relative,absolute,fixed,sticky), in HTML order.
So two simple rules: later in the HTML is on top, and positioned elements are on top of normal ones.
<style>
.card {
width: 160px;
padding: 20px;
border: 2px solid #374151;
font-family: system-ui, sans-serif;
}
.one { background: #fecaca; }
.two { background: #bbf7d0; margin: -20px 0 0 40px; }
.three { background: #bfdbfe; margin: -20px 0 0 80px; }
.lifted {
position: relative; /* positioned: painted above the others */
}
</style>
<div class="card one lifted">1: first in HTML, but positioned</div>
<div class="card two">2: normal</div>
<div class="card three">3: last in HTML</div>
Box 3 covers box 2 because it comes later. But box 1, which is first in the HTML, sits on top of both, only because it has position: relative.
z-index Only Works on Positioned Items
z-index takes a whole number, which can be negative. A higher number is closer to you. But it only has an effect on:
- Positioned elements:
positionisrelative,absolute,fixedorsticky. - Flex items and grid items (the direct children of a flex or grid container), even without
position.
On a normal static element, z-index is simply ignored:
<style>
.box {
width: 150px;
padding: 20px;
border: 2px solid #374151;
font-family: system-ui, sans-serif;
}
.later {
position: relative; /* z-index: auto */
background: #c4b5fd;
margin: -30px 0 0 50px;
}
.a { background: #fde68a; z-index: 10; } /* static: ignored */
.c { background: #fde68a; position: relative; z-index: 10; } /* works */
</style>
<div class="box a">z-index: 10 (static)</div>
<div class="box later">later box</div>
<br>
<div class="box c">z-index: 10 (relative)</div>
<div class="box later">later box</div>
Both yellow boxes have z-index: 10, and both purple boxes are positioned with no z-index. In the first pair the purple box still covers the yellow one, because the yellow box is static and its z-index is ignored. In the second pair the yellow box comes to the front, because it has position: relative. When a z-index “does not work”, the first thing to check is the position.
Stacking Contexts: Why 9999 Can Still Lose
Here is the idea that confuses almost everyone. Some elements create a stacking context: a closed group whose children are stacked only among themselves. The whole group is then placed in the page as one single layer.
Think of school sections. Inside Class 10, roll numbers decide the order: roll number 99 comes after roll number 1. But in the school assembly, all of Class 10 stands together as one group, before or after Class 11. Roll number 99 in Class 10 can never stand behind roll number 1 of Class 11, because the classes are ordered first. In CSS, the dropdown’s z-index is its roll number, and its stacking context is its class.
<style>
body { font-family: system-ui, sans-serif; }
.header {
position: relative;
z-index: 1;
background: #1e3a8a;
color: white;
padding: 12px;
}
.menu {
position: absolute;
top: 40px;
left: 12px;
z-index: 9999; /* huge, but trapped inside .header */
background: #fde047;
color: #111827;
padding: 10px 16px;
}
.banner {
position: relative;
z-index: 2;
background: #f97316;
color: white;
padding: 30px 12px;
}
</style>
<div class="header">
Header (z-index: 1)
<div class="menu">Dropdown (z-index: 9999)</div>
</div>
<div class="banner">Sale banner (z-index: 2) wins</div>
The dropdown has z-index: 9999, but it lives inside the header, and the header (z-index: 1) created a stacking context. The banner has z-index: 2, so the whole header group, including the dropdown, goes below it. Raising the dropdown to a million would not help. The fix is to raise the header.
What Creates a Stacking Context?
The most common causes are:
| Property | Creates a context when |
|---|---|
position: relative or absolute | It also has a z-index other than auto |
position: fixed or sticky | Always |
opacity | The value is less than 1 |
transform, filter | The value is anything other than none |
| A flex or grid item | It has a z-index other than auto |
isolation: isolate | Always: its only job is to create one |
The surprising ones are opacity and transform, because they look like purely visual properties. Watch the green child in each box:
<style>
body { font-family: system-ui, sans-serif; }
.row { display: flex; gap: 12px; }
.parent {
position: relative;
width: 150px;
height: 90px;
padding: 8px;
background: #e5e7eb;
}
.faded { opacity: 0.99; }
.moved { transform: translateX(0); }
.child {
position: absolute;
top: 70px;
left: 30px;
z-index: 5;
padding: 6px 10px;
background: #22c55e;
color: white;
}
.cover {
position: relative;
z-index: 1;
height: 60px;
margin-top: -30px;
background: rgba(239, 68, 68, 0.9);
color: white;
padding: 8px;
}
</style>
<div class="row">
<div class="parent">plain parent<div class="child">child z 5</div></div>
<div class="parent faded">opacity: 0.99<div class="child">child z 5</div></div>
<div class="parent moved">transform<div class="child">child z 5</div></div>
</div>
<div class="cover">Red bar: z-index 1</div>
In the first box, the child’s z-index: 5 competes directly with the red bar’s z-index: 1, so the child appears on top. In the other two boxes, the tiny opacity: 0.99 and the do-nothing transform turned the parent into a stacking context, with no z-index of its own. Now the child is trapped inside, and the whole parent group sits below the red bar.
Remember the hover lift. A card with transform on hover, which you built in the CSS Transforms lesson, becomes a new stacking context while hovered. If a tooltip inside it suddenly goes behind other cards, this is why.
A Simple z-index Scale with CSS Variables
On many projects, z-index values are chosen randomly: 10 here, 999 there, 99999 when something breaks. Soon nobody knows which number to use. A better way is to decide your layers once, as CSS variables, which you learned in the CSS Variables lesson:
<style>
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-popup: 300;
--z-toast: 400;
}
body { font-family: system-ui, sans-serif; }
.layer {
position: relative;
width: 220px;
padding: 14px;
color: white;
border-radius: 8px;
}
.dropdown { z-index: var(--z-dropdown); background: #2563eb; }
.sticky { z-index: var(--z-sticky); background: #16a34a; margin: -20px 0 0 30px; }
.popup { z-index: var(--z-popup); background: #db2777; margin: -20px 0 0 60px; }
.toast { z-index: var(--z-toast); background: #111827; margin: -20px 0 0 90px; }
</style>
<div class="layer toast">Toast message (400)</div>
<div class="layer popup">Offer popup (300)</div>
<div class="layer sticky">Sticky header (200)</div>
<div class="layer dropdown">Dropdown (100)</div>
Every element uses a name, such as var(--z-popup), not a magic number. Leave gaps between the values (100, 200, 300) so you can add a layer in between later. Most websites need only five or six layers in total.
Debugging Overlap in DevTools
When something sits in the wrong layer, follow these steps:
- Right-click the hidden element and choose Inspect. In the Computed tab, check its
positionandz-index. - Walk up the tree in the Elements panel. On each parent, look for
z-index,opacitybelow 1,transform,filterorposition: fixedorsticky. The first one you find is the stacking context that traps your element. - Do the same for the element that is covering it.
- Compare the two contexts, not the two elements. Then raise the context, or remove the property that created it.
You can also untick properties in the Styles panel one at a time and watch the page change. Microsoft Edge has a 3D View tool that shows the layers of the page from the side, which makes stacking easy to see.
Real-Life Example: The Mithai Ghar Diwali Page
Here is the sweet shop page, built correctly. The layers come from one scale. The header is sticky with --z-header. The hero banner needs position: relative and a small --z-raised layer. The dropdown is inside the header, so it rises with it above the banner. The offer popup is fixed in the corner with the highest layer. Scroll the result and hover over Sweets.
<style>
:root {
--z-raised: 1;
--z-header: 100;
--z-dropdown: 110;
--z-popup: 200;
}
body { margin: 0; font-family: system-ui, sans-serif; background: #fffbeb; }
.site-header {
position: sticky;
top: 0;
z-index: var(--z-header);
display: flex;
gap: 20px;
align-items: center;
padding: 12px 16px;
background: #7c2d12;
color: white;
}
.site-header a { color: white; text-decoration: none; }
.nav-item { position: relative; }
.submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
z-index: var(--z-dropdown);
min-width: 160px;
padding: 8px 0;
margin: 0;
list-style: none;
background: white;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.submenu a { display: block; padding: 8px 14px; color: #1f2937; }
.nav-item:hover .submenu,
.nav-item:focus-within .submenu { display: block; }
.hero {
position: relative;
z-index: var(--z-raised);
margin: 16px;
padding: 40px 16px;
background: #f59e0b;
border-radius: 12px;
text-align: center;
}
.content { padding: 0 16px 300px; }
.offer {
position: fixed;
right: 16px;
bottom: 16px;
z-index: var(--z-popup);
max-width: 220px;
padding: 16px;
background: #be123c;
color: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
</style>
<header class="site-header">
<strong>Mithai Ghar</strong>
<div class="nav-item">
<a href="#">Sweets</a>
<ul class="submenu">
<li><a href="#">Kaju Katli</a></li>
<li><a href="#">Rasgulla</a></li>
<li><a href="#">Soan Papdi</a></li>
</ul>
</div>
<a href="#">Gift Boxes</a>
</header>
<section class="hero"><h2>Diwali Gift Boxes Are Here</h2></section>
<div class="content">
<p>Scroll down: the header stays on top. Hover over Sweets to open the menu above the yellow banner.</p>
</div>
<aside class="offer">
<strong>Festival offer!</strong>
<p>Flat 15% off on orders above Rs. 1,000.</p>
</aside>
Try these changes:
- Change
--z-headerto0. Now hover over Sweets and scroll: what goes wrong? - Give the header
opacity: 0.95. Does the dropdown still work? (It does, because the header already has its own z-index layer above the banner.) - Change
--z-popupto0and scroll so the yellow banner passes under the popup. Which one is on top now, and why?
Common Mistakes
- Using
z-indexwithoutposition. On a normal block it does nothing. Addposition: relative, or check that the element is a flex or grid item. - Raising the child instead of the context. If the parent is a stacking context, the child can never escape it. Raise the parent. See the example below.
- Forgetting that
transformandopacitycreate contexts. A harmless looking animation on a parent can trap a dropdown inside it. - Number wars. Writing 9999, then 99999, then 999999 only hides the real problem. Use a small scale of named variables.
Here the header has a transform, so it is a stacking context with no z-index. The dropdown’s 9999 is trapped, and the green slider covers it:
<style>
body { font-family: system-ui, sans-serif; margin: 0; }
header {
position: relative;
transform: translateY(0); /* creates a stacking context */
background: #1f2937;
color: white;
padding: 12px;
}
.dropdown {
position: absolute;
top: 44px;
left: 12px;
z-index: 9999;
background: white;
color: #111827;
padding: 10px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.slider {
position: relative;
z-index: 1;
height: 100px;
background: #10b981;
}
</style>
<header>
Menu
<div class="dropdown">My Orders<br>My Account<br>Logout</div>
</header>
<div class="slider"></div>
Fixed: the header itself gets z-index: 10, which places the whole header group above the slider. The dropdown no longer needs any z-index:
<style>
body { font-family: system-ui, sans-serif; margin: 0; }
header {
position: relative;
z-index: 10; /* raise the whole header */
transform: translateY(0);
background: #1f2937;
color: white;
padding: 12px;
}
.dropdown {
position: absolute;
top: 44px;
left: 12px;
background: white;
color: #111827;
padding: 10px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.slider {
position: relative;
z-index: 1;
height: 100px;
background: #10b981;
}
</style>
<header>
Menu
<div class="dropdown">My Orders<br>My Account<br>Logout</div>
</header>
<div class="slider"></div>
Practice Task: A Bus Pass Renewal Page
The city transport office has a page for renewing bus passes. Right now, the cards slide over the sticky top bar when you scroll, and the help button can hide behind things. Complete the TODO steps:
- Create a z-index scale with three CSS variables: badge, header and chat.
- The top bar stays above the cards while scrolling.
- The “NEW” badge uses the badge layer.
- The Help chat button is above everything, including the top bar.
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
/* TODO 1: create a z-index scale with CSS variables:
--z-badge, --z-header, --z-chat (use gaps like 10, 100, 200) */
.top-bar {
position: sticky;
top: 0;
background: #0f766e;
color: white;
padding: 12px;
/* TODO 2: keep the bar above the cards */
}
.card {
position: relative;
margin: 16px;
padding: 30px 16px;
background: #ccfbf1;
transform: scale(1);
}
.badge {
position: absolute;
top: -10px;
right: -10px;
padding: 4px 8px;
background: #dc2626;
color: white;
/* TODO 3: use the badge layer */
}
.chat {
position: fixed;
right: 16px;
bottom: 16px;
padding: 12px 16px;
background: #111827;
color: white;
border-radius: 20px;
/* TODO 4: the chat button must be above everything */
}
.spacer { height: 600px; }
</style>
<div class="top-bar">Bus Pass Renewal</div>
<div class="card">Monthly pass: Rs. 600 <span class="badge">NEW</span></div>
<div class="card">Student pass: Rs. 300</div>
<div class="spacer"></div>
<button class="chat">Help</button>
Show one possible answer (try it yourself first!)
<style>
:root {
--z-badge: 10;
--z-header: 100;
--z-chat: 200;
}
body { margin: 0; font-family: system-ui, sans-serif; }
.top-bar {
position: sticky;
top: 0;
z-index: var(--z-header);
background: #0f766e;
color: white;
padding: 12px;
}
.card {
position: relative;
margin: 16px;
padding: 30px 16px;
background: #ccfbf1;
transform: scale(1);
}
.badge {
position: absolute;
top: -10px;
right: -10px;
z-index: var(--z-badge);
padding: 4px 8px;
background: #dc2626;
color: white;
}
.chat {
position: fixed;
right: 16px;
bottom: 16px;
z-index: var(--z-chat);
padding: 12px 16px;
background: #111827;
color: white;
border-radius: 20px;
}
.spacer { height: 600px; }
</style>
<div class="top-bar">Bus Pass Renewal</div>
<div class="card">Monthly pass: Rs. 600 <span class="badge">NEW</span></div>
<div class="card">Student pass: Rs. 300</div>
<div class="spacer"></div>
<button class="chat">Help</button>
Key Takeaways
- Without z-index, later elements cover earlier ones, and positioned elements cover normal ones.
z-indexworks only on positioned elements and on flex or grid items.- A stacking context is a closed group. Its children can never rise above an element outside the group that has a higher layer.
opacitybelow 1,transform,filter,fixedandstickypositions, and positioned elements with az-indexall create stacking contexts.- Fix overlap by raising the stacking context, not by adding bigger numbers.
- Keep a small z-index scale in CSS variables, with gaps between the values.