Swil
Frontend
CSSJUL 25, 2026

Why z-index: 9999 Didn't Work

Toggle one property on an ancestor and watch a z-index of 9999 lose to a 2. The number was never the problem.

You gave the dropdown z-index: 9999 and it still hides behind the panel below it. The number was never the problem. Somewhere above it, an ancestor quietly created a stacking context, and a z-index can only compete with its siblings inside the context it lives in — 9999 against 2 is not the comparison being made.

Livetoggle a property on the ancestor
Ancestor card
No stacking context
Dropdown · z-index: 9999
Escapes — paints above the sibling
Sibling panel · z-index: 2
Applied to the ancestor
Looks like it should — but doesn't
No stacking context

Nothing about the dropdown changes as you click. Its z-index stays at 9999 the whole time. What changes is one declaration on its ancestor — and the moment that ancestor establishes a stacking context, the dropdown’s 9999 is sealed inside it. From the outside, the ancestor and everything in it collapse into a single unit painted at the ancestor’s own level, which is auto. The sibling panel’s modest z-index: 2 beats auto, so it paints on top. The dropdown never had a chance.

The rule, in one sentence

z-index is only ever compared between siblings within the same stacking context. Everything else follows from that. A large z-index is not a priority level for the page; it is a sort key inside one box.

The list nobody memorises

The frustrating part is how many ordinary, unrelated-looking properties establish a context. transform and opacity are the famous ones — a CSS animation, a translateZ(0) “GPU hint”, or a fade-in wrapper is usually the real culprit. But filter, backdrop-filter, mix-blend-mode, contain, content-visibility, will-change, and isolation all do it too.

Knowing the negatives

Half the skill is knowing what doesn’t establish one. overflow: hidden is the classic false friend: it will happily clip your dropdown out of existence, which looks like the same bug, but it never isolates z-index. position: relative on its own does nothing either — it needs a z-index other than auto. And display: flex changes whether the children can use z-index, not whether this element establishes a context.

How to actually fix it

Once you accept the rule, the fixes are structural rather than numeric. Move the overlay out of the trapping ancestor entirely — a portal to document.body is the standard answer, and it is why every serious dropdown, modal and tooltip library renders into a portal rather than in place. Failing that, remove the property that established the context, or raise the ancestor itself so the whole unit paints above its sibling. Raising the descendant’s number will never work.

The rules as code

The badge and the reason list in the demo are not hand-written — they come from the function below, which is the exact source this page imports. If you have ever wanted the rules in a form you can read top to bottom instead of scattered across three specs, this is it.

stackingContext.ts
/**
 * Does this element create a stacking context?
 *
 * The rules below are the ones from CSS Positioned Layout / Filter Effects /
 * Compositing. They matter because `z-index` is only ever compared against
 * siblings *inside the same stacking context* — the moment an ancestor creates
 * one, every descendant's z-index is sealed inside it, and no number, however
 * large, can escape. That is the whole reason `z-index: 9999` "doesn't work".
 *
 * Deliberately pure: it takes a plain declaration map and returns the reasons,
 * so the demo on this page and the unit tests both drive the same function.
 */

/** A CSS declaration map, e.g. `{ position: "relative", "z-index": "1" }`. */
export type Declarations = Readonly<Record<string, string>>;

export interface StackingReason {
  /** Stable id — the demo uses it to highlight the toggle that caused it. */
  id: string;
  /** The declaration(s) responsible, formatted for display. */
  declaration: string;
  /** Short explanation shown next to the result. */
  detail: string;
}

export interface ElementContext {
  /** The root element (`<html>`) always establishes one. */
  isRoot?: boolean;
  /** `display` of the *parent*: a flex/grid child with a z-index qualifies. */
  parentDisplay?: string;
}

const NONE = new Set(["none", "", "normal", "auto"]);

const isSet = (value: string | undefined): value is string =>
  value !== undefined && !NONE.has(value.trim().toLowerCase());

const get = (decls: Declarations, property: string): string | undefined =>
  decls[property] ?? decls[property.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())];

/**
 * `will-change` promotes an element as if the listed property were already
 * applied — but only for properties that would themselves create a context.
 */
const WILL_CHANGE_TRIGGERS = new Set([
  "transform",
  "opacity",
  "filter",
  "backdrop-filter",
  "perspective",
  "clip-path",
  "mask",
  "mix-blend-mode",
  "isolation",
  "contain",
  "translate",
  "rotate",
  "scale",
]);

/** `contain` values that include paint or layout containment. */
const CONTAIN_TRIGGERS = ["layout", "paint", "strict", "content"];

function parseOpacity(value: string | undefined): number | null {
  if (value === undefined) return null;
  const trimmed = value.trim();
  if (trimmed.endsWith("%")) {
    const percent = Number.parseFloat(trimmed.slice(0, -1));
    return Number.isNaN(percent) ? null : percent / 100;
  }
  const parsed = Number.parseFloat(trimmed);
  return Number.isNaN(parsed) ? null : parsed;
}

/**
 * Every reason this element establishes a stacking context. An empty array
 * means it does not — its children's z-indexes still compete with the
 * surrounding context.
 */
export function findStackingContextReasons(
  decls: Declarations,
  context: ElementContext = {},
): StackingReason[] {
  const reasons: StackingReason[] = [];
  const position = get(decls, "position")?.trim().toLowerCase();
  const zIndex = get(decls, "z-index")?.trim().toLowerCase();
  const hasZIndex = zIndex !== undefined && zIndex !== "" && zIndex !== "auto";

  if (context.isRoot) {
    reasons.push({
      id: "root",
      declaration: "<html>",
      detail: "The root element always establishes the base stacking context.",
    });
  }

  // Positioning. fixed/sticky qualify on their own; relative/absolute need a
  // z-index other than auto. This asymmetry is the single most-missed rule.
  if (position === "fixed" || position === "sticky") {
    reasons.push({
      id: "position-fixed",
      declaration: `position: ${position}`,
      detail: "fixed and sticky always establish one, with or without a z-index.",
    });
  } else if ((position === "absolute" || position === "relative") && hasZIndex) {
    reasons.push({
      id: "position-z",
      declaration: `position: ${position} + z-index: ${zIndex}`,
      detail: "A positioned element with any z-index other than auto establishes one.",
    });
  }

  const parentDisplay = context.parentDisplay?.trim().toLowerCase();
  const parentIsFlexOrGrid =
    parentDisplay !== undefined && /(^|\s)(inline-)?(flex|grid)$/.test(parentDisplay);
  if (parentIsFlexOrGrid && hasZIndex && position !== "fixed" && position !== "sticky") {
    reasons.push({
      id: "flex-child-z",
      declaration: `z-index: ${zIndex} (flex/grid child)`,
      detail: "A flex or grid child with a z-index qualifies even when position is static.",
    });
  }

  const opacity = parseOpacity(get(decls, "opacity"));
  if (opacity !== null && opacity < 1) {
    reasons.push({
      id: "opacity",
      declaration: `opacity: ${get(decls, "opacity")}`,
      detail: "Any opacity below 1 — 0.999 is enough. The element must be composited as a unit.",
    });
  }

  for (const property of [
    "transform",
    "filter",
    "backdrop-filter",
    "perspective",
    "clip-path",
    "mask",
    "mask-image",
    "mask-border",
    "translate",
    "rotate",
    "scale",
    "offset-path",
  ]) {
    const value = get(decls, property);
    if (isSet(value)) {
      reasons.push({
        id: property,
        declaration: `${property}: ${value}`,
        detail: "Creating a new coordinate/rendering space forces a new stacking context.",
      });
    }
  }

  const blend = get(decls, "mix-blend-mode");
  if (isSet(blend)) {
    reasons.push({
      id: "mix-blend-mode",
      declaration: `mix-blend-mode: ${blend}`,
      detail: "Blending needs a defined backdrop, so the element is isolated into its own context.",
    });
  }

  if (get(decls, "isolation")?.trim().toLowerCase() === "isolate") {
    reasons.push({
      id: "isolation",
      declaration: "isolation: isolate",
      detail: "The one property whose only job is to create a stacking context.",
    });
  }

  const contain = get(decls, "contain")?.trim().toLowerCase();
  if (contain && CONTAIN_TRIGGERS.some((keyword) => contain.split(/\s+/).includes(keyword))) {
    reasons.push({
      id: "contain",
      declaration: `contain: ${contain}`,
      detail: "Layout or paint containment implies a stacking context.",
    });
  }

  if (get(decls, "content-visibility")?.trim().toLowerCase() === "auto") {
    reasons.push({
      id: "content-visibility",
      declaration: "content-visibility: auto",
      detail: "Skipping offscreen rendering requires containment, which implies a context.",
    });
  }

  const willChange = get(decls, "will-change");
  if (willChange) {
    const promoted = willChange
      .split(",")
      .map((entry) => entry.trim().toLowerCase())
      .filter((entry) => WILL_CHANGE_TRIGGERS.has(entry));
    if (promoted.length > 0) {
      reasons.push({
        id: "will-change",
        declaration: `will-change: ${promoted.join(", ")}`,
        detail: "will-change promotes as if the property were already applied — a common surprise.",
      });
    }
  }

  return reasons;
}

/** Convenience predicate over {@link findStackingContextReasons}. */
export function createsStackingContext(
  decls: Declarations,
  context: ElementContext = {},
): boolean {
  return findStackingContextReasons(decls, context).length > 0;
}

/**
 * Properties that people reach for expecting a z-index fix, which do *not*
 * create a stacking context. Knowing the negatives is half the skill.
 */
export const COMMON_FALSE_FRIENDS: ReadonlyArray<{ declaration: string; why: string }> = [
  { declaration: "overflow: hidden", why: "Clips painting, but never isolates z-index." },
  { declaration: "position: relative", why: "Only with a z-index other than auto." },
  { declaration: "z-index: 9999 (on a static element)", why: "z-index is ignored unless positioned or a flex/grid child." },
  { declaration: "display: flex", why: "Affects the children's z-index eligibility, not this element." },
];