Every developer hits the moment when z-index stops making sense. You set z-index: 9999 on your tooltip, but it still hides behind a sibling. The missing piece is the stacking context. By the end of this article, you will understand what creates stacking contexts, how they reorder painting, and how to build dependable layers for tooltips, badges, dropdowns, and overlays. You will also learn to diagnose z-index bugs and fix them without magic numbers.
Why Stacking Contexts Matter
z-index controls the painting order only inside a stacking context. If two elements belong to different stacking contexts, the one in the higher context wins, no matter how large the other z-index value is. This means a child with z-index: 1 can paint above a neighbor with z-index: 9999 if the child sits in a stacking context that stacks above the neighbor. Understanding who creates a stacking context, and where, gives you control over layers that is predictable and debuggable.
Common triggers include transform, opacity less than 1, position with a non-auto z-index, position: fixed, filter, mix-blend-mode, perspective, isolation: isolate, and contain: paint. The root element also forms one. Many frameworks add transform to containers for GPU acceleration, which silently creates new stacking contexts and surprises tooltips and menus. Knowing these rules turns layering from trial-and-error into a plan.
Prerequisites
You only need the basics. We will use semantic HTML, CSS custom properties for a z-index scale, and pseudo-elements to draw a tooltip arrow.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The demo has a header, a grid of cards, a tooltip anchored to a button, a small badge on the image, and a fullscreen overlay. This layout lets us explore parent and child layering, inter-card stacking, and page-level layering. The tooltip includes an arrow drawn with borders. The overlay sits near the end of the body to control global stacking.
<!-- HTML -->
<header class="site-header">
<h1>Stacking Context Lab</h1>
<nav>
<button class="header-cta">Profile</button>
</nav>
</header>
<main class="stage">
<section class="grid">
<article class="card" aria-describedby="tip-1">
<img class="card-img" src="https://picsum.photos/400/240" alt="Random scenic photo">
<span class="badge" aria-hidden="true">3</span>
<div class="card-body">
<h2 class="card-title">Layered Card</h2>
<p>Hover the info button to show the tooltip.</p>
<button class="info-btn" aria-haspopup="true" aria-expanded="false">Info</button>
</div>
<div id="tip-1" class="tooltip" role="tooltip">
<p>This tooltip lives inside a stacking context created by the card.</p>
</div>
</article>
<article class="card muted">
<img class="card-img" src="https://picsum.photos/400/241" alt="Random scenic photo">
<span class="badge" aria-hidden="true">1</span>
<div class="card-body">
<h2 class="card-title">Neighbor Card</h2>
<p>This card sits under the header and above the page background.</p>
</div>
</article>
</section>
</main>
<div class="overlay" aria-hidden="true"></div>The header sits above the rest of the page. Each .card will establish its own stacking context for internal layering. The overlay is a page-level layer that can be toggled, which will help us compare global stacking against card-local stacking.
Step 2: The Basic CSS & Styling
We will set up a z-index scale with custom properties, a comfortable layout, and clear backgrounds. We also define typography and card visuals that keep focus on the layers. The key part is the layered scale: –z-page, –z-header, –z-overlay, and internal scales for cards.
/* CSS */
:root {
--bg: #0f172a;
--panel: #111827;
--panel-2: #0b1220;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #22d3ee;
--danger: #ef4444;
/* Page-level z-index scale */
--z-page: 0;
--z-header: 20;
--z-overlay: 40;
/* Card-local z-index scale (applies inside each card's stacking context) */
--z-card-base: 0;
--z-card-img: 1;
--z-card-badge: 2;
--z-card-tooltip: 3;
}
*,
*::before,
*::after { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
color: var(--text);
background: linear-gradient(180deg, #0b1020 0%, #0a0f1a 100%);
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, sans-serif;
}
.site-header {
position: sticky;
top: 0;
z-index: var(--z-header);
background: rgba(17, 24, 39, 0.9);
backdrop-filter: saturate(140%) blur(6px);
border-bottom: 1px solid #1f2937;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.stage {
padding: 2rem 1rem 6rem;
max-width: 1100px;
margin: 0 auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
}
.card {
position: relative;
/* The transform creates a stacking context for the whole card */
transform: translateZ(0);
background: var(--panel);
border: 1px solid #1f2937;
border-radius: 12px;
overflow: visible;
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
}
.card.muted { background: var(--panel-2); }
.card-img {
display: block;
width: 100%;
height: auto;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
z-index: var(--z-card-img);
position: relative;
}
.card-body {
padding: 1rem;
position: relative;
z-index: var(--z-card-base);
}
.card-title { margin: 0 0 0.25rem; font-size: 1.1rem; }
.header-cta, .info-btn {
color: #0a0f1a;
background: var(--accent);
border: 0;
padding: 0.45rem 0.7rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.badge {
position: absolute;
top: 8px;
right: 8px;
z-index: var(--z-card-badge);
background: var(--danger);
color: white;
width: 28px;
height: 28px;
display: grid;
place-items: center;
font-size: 0.8rem;
border-radius: 50%;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.4);
}
.tooltip {
position: absolute;
left: 1rem;
bottom: calc(100% - 12px);
min-width: 220px;
max-width: 280px;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.75rem;
color: var(--text);
box-shadow: 0 18px 40px rgba(0,0,0,0.5);
z-index: var(--z-card-tooltip);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.info-btn:hover + .tooltip,
.info-btn:focus + .tooltip {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tooltip::after {
/* Arrow */
content: "";
position: absolute;
left: 16px;
bottom: -8px;
border-width: 8px 8px 0 8px;
border-style: solid;
border-color: #0b1220 transparent transparent transparent;
}
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
background: rgba(2, 6, 23, 0.6);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
.overlay.is-visible {
opacity: 1;
pointer-events: auto;
}Advanced Tip: Keep a named z-index scale in custom properties for page layers (header, overlay, modals). Keep a separate local scale for component internals. This avoids clashes and clarifies intent during reviews.
Step 3: Building the Card and Tooltip
The card uses transform: translateZ(0) to demonstrate a common pitfall. That transform makes the card a new stacking context. The badge and tooltip stack by their local z-index only against other content inside the card. The tooltip arrow uses borders, which is the same technique you would use when building a CSS tooltip shape or a small triangle up for pointers. Hover or focus the info button to reveal the tooltip, which must appear above the image and badge but still below any page-level overlay.
/* CSS */
.card {
position: relative;
transform: translateZ(0); /* new stacking context */
}
.badge {
position: absolute;
z-index: var(--z-card-badge); /* higher than image, lower than tooltip */
}
.tooltip {
position: absolute;
z-index: var(--z-card-tooltip); /* highest inside the card */
}
.info-btn:hover + .tooltip,
.info-btn:focus + .tooltip {
opacity: 1;
transform: translateY(0); /* animates within the same context */
}
.tooltip::after {
/* triangle arrow via borders */
border-width: 8px 8px 0 8px;
border-style: solid;
}How This Works (Code Breakdown)
The transform on .card creates a stacking context even without z-index. This is significant because it confines z-index decisions inside the card. No amount of z-index on .badge or .tooltip will reach over a neighboring card or the header. This is why “z-index: 9999” fails during dropdown bugs when a parent has transform or opacity. Removing the transform collapses the local context back into the parent context.
The badge is placed with position: absolute and a local z-index above the image. The tooltip uses a higher local z-index so it clears both. Because the tooltip is absolutely positioned, it is taken out of document flow but still respects the nearest positioned ancestor, which is the card.
The tooltip arrow is drawn using border triangles. That technique mirrors standalone triangle recipes. If you want a refresher on border-based pointers, see the CSS tooltip shape and the dedicated triangle up pattern for more variations.
Step 4: Building the Header and Overlay
Now we layer the header and a page-level overlay. The header uses position: sticky with a z-index above the page content, so it always sits on top of the cards. The fixed overlay creates its own stacking context and sits above the header with a higher page-level z-index token. This makes it clear that page layers beat card-local layers even when those local layers have high numeric values.
/* CSS */
.site-header {
position: sticky;
top: 0;
z-index: var(--z-header); /* page-level layer above cards */
}
.overlay {
position: fixed; /* fixed creates a stacking context */
inset: 0;
z-index: var(--z-overlay); /* highest page layer in this demo */
}
/* Demo-only trigger for overlay: press Profile to toggle with a :has() hook if supported */
.stage:has(.header-cta:active) + .overlay,
.site-header:has(.header-cta:active) ~ .overlay {
opacity: 1;
pointer-events: auto;
}How This Works (Code Breakdown)
The header and overlay are part of the root stacking context, not the card stacking contexts. The header gets a page-level z-index via –z-header and stays above cards even though cards have box-shadows that look elevated. The overlay uses position: fixed which forms a stacking context on its own, then we assign a higher page-level z-index via –z-overlay. When the overlay is visible, it covers both header and cards, and it also covers every tooltip because those tooltips live inside card contexts that paint earlier.
This arrangement models a common product surface: cards, local popovers, a sticky app bar, and a modal scrim. If your popover must escape the card to sit above the overlay, move it to a top-level portal container that sits next to the overlay and header. Treat that container as a separate page layer with its own z-index token.
Advanced Techniques: Animations & Hover Effects
We can refine the motion and still preserve clarity. The tooltip and the overlay already transition opacity. We can improve perception with a subtle elevation rise while keeping the stacking behavior intact. We will also add a reduced-motion fallback.
/* CSS */
@keyframes rise {
0% { opacity: 0; transform: translateY(6px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.tooltip {
will-change: transform, opacity; /* hint: compositor-ready properties */
}
.info-btn:hover + .tooltip,
.info-btn:focus + .tooltip {
animation: rise 160ms ease both;
}
/* Overlay fade-in already defined. Add a subtle backdrop filter on capable devices */
@supports (backdrop-filter: blur(2px)) {
.overlay.is-visible {
backdrop-filter: blur(2px);
}
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce) {
.tooltip,
.overlay {
transition: none;
animation: none;
}
}The rise animation tweaks opacity and transform only, which keeps the animation on the compositor. We avoid animating box-shadow because that often triggers expensive repaints. The will-change hint helps, but use it on dynamic hover targets only. Do not add it to the entire page because it can increase memory usage.
Accessibility & Performance
Accessibility
Decorative elements like the badge should use aria-hidden=”true” so screen readers do not announce visual counters that do not inform interaction. If the badge provides critical information, move it into the button label or ARIA label instead. The tooltip uses role=”tooltip” and is referenced by aria-describedby on the card. For production, you will want to toggle aria-expanded on the trigger, manage focus on open, and close on Escape. Prevent hover-only tooltips from locking content behind inaccessible hover states on touch devices by enabling a click trigger or an always-visible help icon.
Honor motion settings with prefers-reduced-motion, which we included. When layering icons or shapes inside the card, keep sufficient color contrast and avoid transparent overlays that reduce legibility. If your tooltip arrow is critical to meaning, make sure the relationship between the tooltip and its anchor remains clear even when the arrow is not visible. The pointer is a visual affordance; the programmatic relationship comes from focus management and aria attributes.
Performance
Stacking contexts are cheap, but unnecessary ones add complexity and harm debuggability. Only add transform when you need it for layout, animation, or GPU promotion. transform, filter, and opacity create new contexts that can trap children; avoid sprinkling translateZ(0) on containers by default. Keep your z-index scale small and named. Large random values hide problems rather than solve them.
For shapes inside layered components, prefer border-based or clip-path-based shapes that do not trigger layout thrash. For example, the tooltip arrow here is a border triangle, which paints quickly. If you prefer a more stylized pointer like a chat bubble, the same layering rules apply. You can build the bubble with a rounded rectangle plus a triangle; see the speech bubble pattern for ideas.
The last closing paragraph
You built a layered card, a reliable tooltip, a sticky header, and a page overlay while learning how stacking contexts govern z-index. You can now read the page as layers rather than numbers and place components where they belong. Apply these patterns to menus, popovers, and shape-driven UI, and you will ship stacks that behave exactly as you intend.