Building in Public#building-in-public

CSS Stacking Context: The Search Dropdown Bug

By Mohamed Abdi Guled

Raising a dropdown's z-index to the maximum possible value did nothing — the bug was a stacking context, not the number.

CSS Stacking Context: The Search Dropdown Bug

ShabelleHub's search dropdown was being covered by the CTA buttons below it. We could see the dropdown appearing, we could see the buttons, and we could not understand why z-index: 9999 was not fixing it. We raised it to 99999. Still covered. We tried z-index: 2147483647 — the maximum 32-bit integer. Still covered.

The problem was not the z-index value. It was the stacking context.


What Happened

The hero section of the ShabelleHub homepage has a search bar at the top and two CTA buttons below it — "Explore Tools" and "Latest AI Reviews." When a user typed in the search bar, a dropdown appeared with matching tools. The dropdown appeared behind the CTA buttons, making the bottom results unreadable.

The initial diagnosis was obvious: the dropdown's z-index is too low. Raise it.

/* HeroSearch.module.css */
.suggestions {
  z-index: 20; /* raised to 200, then 9999, then higher — still wrong */
}

Raising the z-index did not fix it, which meant the problem was not the z-index value.


How Stacking Contexts Work

A stacking context is an independent rendering layer in the browser. Elements within a stacking context are painted together and their z-index values only compete with other elements within the same stacking context — not with elements in parent or sibling contexts.

A new stacking context is created when an element has, among other things:

  • position other than static and a z-index other than auto
  • opacity less than 1
  • transform other than none
  • filter other than none
  • isolation: isolate

The key rule: z-index only competes within the same stacking context. An element with z-index: 9999 inside a stacking context that is painted below another stacking context will still appear below that other stacking context, regardless of its z-index value.


The Actual Problem

The hero section structure:

<section> ← creates stacking context (has transform for background animation)
  <div class="wrap"> ← search container
    <div class="searchBox">...</div>
    <ul class="suggestions"> ← z-index: 9999 (within .wrap's context)
  </div>
  <div class="ctaButtons"> ← CTA buttons
    <button>Explore Tools</button>
    <button>Latest AI Reviews</button>
  </div>
</section>

The <section> element had a transform: translateZ(0) applied as a GPU compositing hint for the background animation. This created a stacking context.

Inside that stacking context, .wrap had position: relative but no z-index — so it did not create its own stacking context. The .suggestions dropdown's z-index: 9999 competed with .ctaButtons inside the <section>'s stacking context.

But .ctaButtons was rendering after .suggestions in the DOM, which means it painted on top — DOM order determines paint order when stacking contexts are at the same level. The z-index on .suggestions had no effect because .suggestions and .ctaButtons were competing within the same parent context, and DOM order was winning.

The fix was not to raise .suggestions's z-index further. The fix was to give .wrap its own stacking context, so .suggestions would be inside a context that paints above .ctaButtons.


The Fix

Two properties added to .wrap:

/* HeroSearch.module.css — fixed */
.wrap {
  max-width: 480px;
  margin: 0 auto;
  position: relative;
  z-index: 300;        /* creates a stacking context for .wrap */
  isolation: isolate;  /* explicitly isolates .wrap's children */
}

.suggestions {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  right: 0;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  list-style: none;
  overflow: hidden;
  z-index: 9999;       /* within .wrap's context — now above .ctaButtons */
}

z-index: 300 on .wrap creates a new stacking context. .wrap's stacking context now competes with .ctaButtons's stacking context at the <section> level — and .wrap wins because its z-index (300) is higher than .ctaButtons (which has no z-index, so it defaults to auto/0).

isolation: isolate is added as an explicit guarantee. It creates a stacking context regardless of whether other properties (z-index, transform, opacity) would do so — making the intent clear in the code.


How to Diagnose Stacking Context Bugs

When z-index is not working as expected:

Step 1: Check if the parent creates a stacking context.

Open DevTools → Layers panel (Chrome). The layers panel shows every compositing layer and stacking context. An element you expected to be above another but is not will show as being in a different layer than expected.

Step 2: Check for these stacking-context-creating properties on ancestors:

position + z-index (not auto)
opacity < 1
transform (any value other than none)
filter (any value other than none)
will-change: transform/opacity/filter
isolation: isolate

Any of these on a parent element creates a stacking context that contains the problematic element.

Step 3: Find the common ancestor stacking context.

The element you want on top and the element covering it both live inside some ancestor stacking context. Their z-index values only compete within that context. Raising the z-index of the covered element past the maximum possible value will not help if it is in a lower-priority ancestor context.

Step 4: Restructure to create the right stacking context.

The correct fix is almost always: give the container of the element that should be on top its own stacking context with a higher z-index than the sibling context. isolation: isolate with a z-index is the clearest way to do this.


The Properties That Create Stacking Contexts

For reference — the complete list of CSS properties that create a new stacking context:

position: fixed or sticky (always)
position: absolute or relative, with z-index not auto
opacity < 1
transform (anything other than none)
filter (anything other than none)
backdrop-filter
perspective
clip-path
mask / mask-image / mask-border
isolation: isolate
will-change: any property that would create a context
contain: layout / paint / strict / content

Any of these on an element creates a new stacking context. Elements inside that context compete on z-index only within it.


The Lesson

z-index is not a global priority system. It is a local priority system within a stacking context. When z-index: 9999 is not working, the question is never "should I use a higher number?" The question is "what stacking context is this element in, and what context should it be in instead?"

isolation: isolate is the most useful tool for controlling stacking context deliberately. When you want a container and all its children to form an independent compositing group — and be immune to the stacking context implications of whatever CSS properties its children use — isolation: isolate makes that intent explicit.

The ShabelleHub search dropdown bug took too long to diagnose because we kept trying to fix the symptom (z-index value) rather than the cause (wrong stacking context). Understanding the stacking context model turns a confusing class of bugs into a class with a clear diagnostic process.


*This article is part of the ShabelleHub Building in Public series.*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog