How do you stop z-index values from escalating into an arms race across a large CSS codebase, and what role does isolation: isolate play?
answer
- one global namespace invites escalation
- name the layers, ban raw integers
- make component internals private
- isolate has no visual side effect
- containment blocks escape too
basics
~20 sTreat z-index as a small, named set of page-level layers rather than free integers, and give each component root isolation: isolate so its internal values stay internal. Escalation happens only when every component competes in one global context.
solid answer
~50 sThe arms race is a symptom of one shared namespace: if every component's `z-index` lands in the root stacking context, the only way to win is a bigger number, and nothing is ever safe to lower again. Two moves fix it structurally. First, define a **small, named layer scale** at the application level — a handful of custom properties such as `--layer-dropdown`, `--layer-modal`, `--layer-toast`, with deliberate gaps — and forbid raw integers outside that file, so ordering becomes a decision reviewed in one place rather than a number typed in a hurry. Second, put `isolation: isolate` on component roots. It creates a stacking context with no other visual effect, so a component's internal `z-index: 1` and `z-index: 2` are compared only against each other and can never leak into the global order. The component then stacks as a single unit, positioned by the layer scale, and its internals become genuinely private.
code
css · 13 lines:root {
--layer-dropdown: 100;
--layer-sticky: 200;
--layer-modal: 400;
--layer-toast: 500;
}
.site-header { position: sticky; top: 0; z-index: var(--layer-sticky); }
.modal { position: fixed; inset: 0; z-index: var(--layer-modal); }
.card { isolation: isolate; }
.card__badge { position: absolute; z-index: 2; }
.card__shadow { position: absolute; z-index: 1; }go deeper
Do not invent a new large z-index when something is hidden. Look for an existing named layer value in the codebase and use it, or ask which layer the element belongs to.
Explain why isolation: isolate contains a component's internal z-index values, and why small internal numbers are fine once the boundary exists.
Argue the design: one named scale with a single owner for page-level layers, isolated component roots for everything else, and a portal for overlays that must cross the boundary.
Own the tradeoff and the rollout — where the boundary sits, which teams may add a layer, how existing 9999s are migrated without a big-bang renumber, and how the rule is enforced in review or lint.
## Why the arms race happens `z-index` in the root stacking context is a **global mutable variable with no owner**. Any file can write to it, nothing declares intent, and the effect of a change is visible only when two features happen to overlap on one screen. Under those conditions escalation is rational behaviour: a developer whose dropdown is hidden has no cheap way to discover the correct value, so they pick something safely large. The next person does the same. `9999`, `99999`, `2147483647` — the sequence is a symptom of missing structure, not of carelessness. The damage is not aesthetic. Nobody can lower a value later, because nobody knows what depended on it. The stylesheet has accumulated coupling that no tooling can see. ## Move one: a small named scale Replace the open integer space with a closed, ordered vocabulary defined in exactly one place: ```css :root { --layer-base: 0; --layer-dropdown: 100; --layer-sticky: 200; --layer-overlay: 300; --layer-modal: 400; --layer-toast: 500; } .modal { z-index: var(--layer-modal); } ``` The gaps exist so an unforeseen layer can be inserted without renumbering. The rule that gives it force is social, not technical: **raw integers are not allowed outside this file**, enforced in review or by a lint rule. The point is that "is a toast above a modal?" becomes a product decision made once and readable in five lines, instead of an emergent property of a hundred files. Note what the scale is *not*: it is not a per-component value. Only things that genuinely compete at page level — overlays, sticky headers, toasts — get an entry. A card's internal badge does not. ## Move two: isolation as a boundary `isolation: isolate` creates a stacking context and does nothing else. No alpha compositing like `opacity: 0.99`, no containing-block change like `transform`, no layer promotion like `will-change`. It is the only declaration on the stacking-context list whose sole purpose is the boundary itself, which makes it self-documenting: a reader sees it and knows the intent was containment. Put it on component roots: ```css .card { isolation: isolate; /* internal z-index stays internal */ } .card__badge { position: absolute; z-index: 2; } .card__shadow { position: absolute; z-index: 1; } ``` Now `2` and `1` are compared only with each other. The card as a whole participates in the page order at whatever level its container assigns. A component author can use small, honest numbers without knowing anything about the rest of the application — which is the actual goal. Encapsulation of stacking is the same idea as encapsulation of anything else: make the internals unobservable so they are free to change. ## The tradeoff to state honestly Isolation cuts both ways, and a principal-level answer says so. A component that must *escape* its container — a dropdown that overflows a card, a tooltip near a viewport edge — cannot escape an isolated ancestor. The subtree is atomic by design. So the architecture needs a deliberate exit for those cases: render escaping overlays into a container near the end of `<body>` rather than inside the component that triggers them, so they and the page compete as siblings under the named scale. Modern platform features narrow the problem further — the top layer used by `<dialog>` and popover-backed elements sits above the entire page regardless of stacking contexts — but the general portal pattern still carries most real overlays. The decision, therefore, is per-component and explicit: **contained by default, escaping by exception**, with the exceptions rendering elsewhere rather than fighting their way out with numbers. ## What to say when asked Frame it as removing a shared mutable namespace, not as a style preference: a small named scale gives page-level layering a single owner, `isolation: isolate` makes component-level layering private, and overlays that must cross the boundary are relocated rather than escalated. Then name the cost — isolation traps things that need to escape — and describe how you handle it. Naming the cost is what separates a considered position from a rule copied off a blog.
- What breaks when you apply isolation: isolate to every component root indiscriminately?Anything that needs to visually escape its container. A dropdown inside an isolated card can no longer paint above content outside the card, no matter what z-index it uses, because the card is atomic. Escaping overlays need to render outside the component — typically in a container near the end of `<body>` — rather than rely on numbers.
- Why prefer isolation: isolate over opacity: 0.999 or transform: translateZ(0) to create the boundary?Both alternatives work but carry baggage. `opacity` below 1 adds a real compositing step, and `transform` also makes the element a containing block for fixed descendants and usually triggers layer promotion. `isolation: isolate` creates the stacking context and nothing more, and it states its intent to the next reader.
- How do you migrate an existing stylesheet that already has 9999s scattered through it?Do not renumber globally first. Start by isolating component roots so their internal values stop mattering, which shrinks the set of genuinely global values dramatically. Then map the survivors onto a named scale, replace them file by file, and add a lint rule banning raw integers so the problem cannot reappear.
saying these in an interview costs you the question
- Solves overlap by always choosing a larger number
- Believes a global z-index scale alone prevents conflicts
- Applies isolation everywhere without considering escaping overlays
- Treats z-index values as documentation-free implementation details
- Uses opacity or transform hacks where isolation states the intent