The Real Problem With z-index (It’s Not the Stacking Context)

Most CSS resources treat z-index as a technical problem. They explain stacking contexts, paint order, and the difference between positioned and non-positioned elements. What they rarely discuss is the real source of chaos: the values developers actually choose.

Screenshot of a code editor with a large number of z-index values, many of which include the !important keyword.

Once a project grows beyond a single team, z-index values tend to devolve into what developers call “magic numbers.” A developer on Team A sets a modal to z-index: 100. Team B, worried their dropdown might get hidden, sets theirs to 1000. Someone else, playing it safe, jumps to 10000. There is no system behind these choices—only fear.

The Arms Race Mentality

A few years ago, I saw a pull request with a staggeringly large z-index value. When I asked the author why they chose that particular number, the answer was telling: “I just wanted to make sure it was above all the other elements on the page.”

That logic is understandable. In large projects with multiple teams, you never know what else is floating on the screen—a toast from one team, a promotion banner from another, a modal from a third-party SDK. When you can't see the whole picture, picking a huge number feels like the only safe move.

It’s worth noting that there is a hard ceiling: the maximum z-index value is 2147483647, the upper limit of a 32-bit signed integer. Browsers will clamp anything higher. But bigger numbers don’t solve the fundamental problem, because z-index only works within the same stacking context. An element with a huge value can still be hidden behind an element with a tiny value if they belong to different stacking contexts.

The consequence of arbitrary values is predictable:

  • Poor maintainability: A value like 10001 silently communicates nothing about its relationship to other elements.
  • Conflict generation: Multiple teams guessing high numbers inevitably collide.
  • Debugging pain: When something is hidden, tracing the cause becomes painful when dozens of elements carry colossal arbitrary values.

Tokenize Everything

The cleanest fix I’ve found is pragmatic—remove the deliberate guessing by turning z-index into a small, deliberate system of named layers. Tokenizing z-index values and defining them once in a central place eliminates the guesswork without demanding exotic CSS techniques.

Most well-constructed design systems already ship with z-index tokens, even if few people bother to detail why. The benefits stack up quickly:

  • You manage values in one place, so maintaining a layer is a one-line change.
  • Team A can’t unknowingly top Team B’s layers with a number that happens to be higher.
  • Debugging reveals which “layer” a component belongs to, immediately telling you if your logic is at fault, instead of your value.

A Minimal Working System

Here is a practical, scalable layer system defined as CSS custom properties in the root:

:root {
  --z-base: 0;
  --z-toast: 100;
  --z-popup: 200;
  --z-overlay: 300;
}

Want to shift toasts above the overlay? You change the values in one spot, and every element that references the related layer adjusts without touching its own styles.

The real payoff appears when your requirements shift. Say you need a sidebar that should sit between the base content layer and the toast layer. With tokens, you aren’t hunting through component files to check values. Add your token and rebalance the numbers:

:root {
  --z-base: 0;
  --z-sidebar: 100;
  --z-toast: 200;
  --z-popup: 300;
  --z-overlay: 400;
}

None of your existing components need modification. The layer system absorbs the change gracefully.

Relative Values for Tightly Coupled Layers

Some elements conceptually belong together. A modal overlay background, for instance, should always sit exactly behind the modal content—and only a step behind it.

Using calc() allows you to express that connection directly and keep the two values in lockstep forever:

.overlay-background {
  z-index: calc(var(--z-overlay) - 1);
}

Regardless of what you later assign to --z-overlay, its backdrop will always be precisely one position lower.

Local Layers for Component Internal Logic

The global token scale solves macro-level layering, but most components create their own stacking contexts. Inside a popup with z-index: 300, a child with z-index: 301 behaves identically to one with z-index: 1. There is no functional need for globally large numbers inside a contained context; worse, they erode the meaningfulness of the scale you’ve already named.

For internals, scope the problem locally. Introduce two anchor tokens that position components against their nearest stacking context:

:root {
  /* ... global tokens ... */

  --z-bottom: -10;
  --z-top: 10;
}

Place any child element using these anchors. A floating action button inside a popup can hold z-index: var(--z-top), and a decorative underline can sit below both with z-index: var(--z-bottom):

.popup-close-button {
  z-index: var(--z-top);
}

.toast-decorative-icon {
  z-index: var(--z-bottom);
}

You can even combine them with calc() when a component needs a second internal tier: calc(var(--z-top) + 1) or calc(var(--z-top) - 1). These values are expressive because they encode relative intent, not arbitrary magnitude.

Why Negative Values Are Safe Here

Many developers avoid z-index: -1 out of fear that it will sink beneath the page background. But if the component establishes its own stacking context, that range is safely confined. A negative value tells the element exactly “place me behind this component’s default render order,” not “place me behind the universe.”

That behavior makes negative values ideal for certain component decorations:

  • Subtle background textures called in behind the positioning backdrop
  • Control-heavy shadow simulators
  • Faux under-borders

The Tooltip Problem

Tooltips introduce an apparent dilemma because they can appear nearly anywhere. Developers assume the safest route is a staggeringly high value like 9999, thinking it will always snap above every modal. Yet a tooltip nested inside the modal’s DOM can never climb above the modal if its layer token is global—everything inside a stacking context stays contained.

Since a tooltip only needs to be above the content it is attached to, the local token system solves what global guessing can’t:

.tooltip {
  z-index: var(--z-top);
}

By tying the tooltip to the component’s local stack rather than to a global rank, the icon in a toast, the link in a popup, and the button inside the main body all receive tooltips that correctly render on top of their immediate surroundings.

The Guidelines That Keep z-index Under Control

  1. No raw numbers. Every z-index should reference a token. A literal value represents an unmanaged layer.
  2. Expose everything central. Keep your global set of layers readable in one file, expressed as semantic custom properties.
  3. Expect context mistakes, not layer mistakes. If an element with a huge value is still hidden, search for its stacking context. A component nested inside a card or popup may never escape that parent.
  4. Commit to layers, not to heights. Ask what layer your element belongs to first. Calculating a number is shallow by comparison.
  5. Keep coupled values close. A layered parent and its background must always stay glued to one another via calc(), not two unrelated tokens that drift apart.
  6. Protect internal layers actively. Give each component an internal, close-range scale of magnitudes via short and semantic local tokens.

The value of z-index was never about how high you go, but about how consistent the system behind it is. With these thoughtful, foundational tokens, you make z-index logic predictable, legible, and most importantly, trivial to maintain.

Automating Enforcement

Style guides degrade when they run without enforcement. A developer debugging a deadline-day rendering issue will reach for a bare z-index: 999 the moment no automated complaint arrives. To keep the system honest long term, we built the open-source z-index-token-enforcer library. It automates static enforcement by flagging any literal values that bypass your sanctioned tokens:

npm install z-index-token-enforcer --save-dev
  • Stylelint plugin—polyfills enforcement inside standard CSS and SCSS
  • ESLint plugin—catches literal values in JavaScript-based styling and inline style objects
  • Command-line scanner—quickly audits whole directories or runs it from CI/CD tooling without further configuration

Make the rules mandatory rather than recommended. Your z-index value is meaningful only when it is a deliberate, tracked answer deep inside your system—not an individual gamble that outlives its creator.