# z-index Only Works Inside Its Stacking Context

**SHOULD** · **ID:** `layout-stacking-context-zindex` · **Category:** layout
**Source:** [Web Platform](https://web.dev/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/layout-stacking-context-zindex

> SHOULD: z-index is compared only within a stacking context, not globally. transform, opacity<1, filter, will-change, position:fixed/sticky, isolation, and flex/grid children with z-index all create one — trapping descendants no matter how high their z-index. Do not fix overlap with a bigger number; remove the accidental context or lift the overlay out of the trapping ancestor.

A parent with transform, opacity, or filter makes a new stacking context that traps its children's z-index — the fix is not a bigger number

The recurring bug: a dropdown set to `z-index: 9999` still renders under the next section and nobody can explain why the number does not win. z-index is not global — it is compared only among siblings inside the same STACKING CONTEXT, and a long list of ordinary CSS silently creates one: `transform`, `opacity` below 1, `filter`, `backdrop-filter`, `will-change`, `position: fixed`/`sticky`, `isolation: isolate`, and being a flex/grid child that has a z-index. Once a parent forms a context, its descendants' z-index are resolved only against each other and the whole subtree is clamped to the parent's own place in the stack — so a `9999` inside a `transform`ed card can never climb above a sibling card that comes later in the DOM. Raising the number is the wrong fix and starts the arms race that layout-ibelick-z-index-scale exists to prevent. The right fixes: remove the accidental context (drop the stray `transform`/`opacity`), or lift the overlay out of the trapping ancestor entirely — the same escape move layout-impeccable-clipped-overflow prescribes for a popover trapped in an `overflow` ancestor.

## Rule snippet

```tsx
/* z-index:9999 inside a transformed parent can't beat a later sibling. */
.card { transform: none; } /* or move the overlay to a portal / higher context */
```

## Bad — do not do this

`layout-stacking-context-zindex-bad`

```tsx
export function StackingContextZindexBad() {
  return (
    <div className="w-full max-w-sm">
      <div className="relative h-44 rounded-lg border border-border bg-background">
        {/* Card A — earlier in DOM. `transform` makes it a stacking context. */}
        <div
          className="absolute rounded-lg border border-border bg-card p-2 text-xs text-foreground"
          style={{ left: 12, top: 16, width: 150, height: 100, transform: 'translateZ(0)' }}
        >
          Card A (has transform)
          {/* z-9999, but trapped inside Card A's context */}
          <div
            className="absolute rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground"
            style={{ left: 96, top: 52, zIndex: 9999 }}
          >
            badge z-9999
          </div>
        </div>
        {/* Card B — later in DOM, overlaps Card A and the badge's position */}
        <div
          className="absolute rounded-lg border border-border bg-muted p-2 text-xs text-foreground"
          style={{ left: 104, top: 40, width: 155, height: 96 }}
        >
          Card B
        </div>
      </div>
      <p className="mt-4 text-xs text-destructive">
        The badge is gone: z-index:9999 is trapped in Card A’s transform context, so later-painted Card B covers it.
      </p>
    </div>
  );
}
```

## Good — do this

`layout-stacking-context-zindex-good`

```tsx
export function StackingContextZindexGood() {
  return (
    <div className="w-full max-w-sm">
      <div className="relative h-44 rounded-lg border border-border bg-background">
        {/* Card A — no transform, so it creates no stacking context */}
        <div
          className="absolute rounded-lg border border-border bg-card p-2 text-xs text-foreground"
          style={{ left: 12, top: 16, width: 150, height: 100 }}
        >
          Card A (no transform)
          {/* z-9999 now resolves against the page, not against Card A */}
          <div
            className="absolute rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground"
            style={{ left: 96, top: 52, zIndex: 9999 }}
          >
            badge z-9999
          </div>
        </div>
        {/* Card B — same position as the bad example */}
        <div
          className="absolute rounded-lg border border-border bg-muted p-2 text-xs text-foreground"
          style={{ left: 104, top: 40, width: 155, height: 96 }}
        >
          Card B
        </div>
      </div>
      <p className="mt-4 text-xs text-success">
        Same z-9999, same Card B — but with no accidental context the badge resolves against the page and sits on top.
      </p>
    </div>
  );
}
```

## References

- [MDN — The stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Stacking_context)
- [nextlevelbuilder — ui-ux-pro-max](https://skills.sh/nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max)
- [MDN — z-index](https://developer.mozilla.org/en-US/docs/Web/CSS/z-index)
