CORE JSC

International Technology Partnership

UI/UX

Fixing Nested Modals That Render Behind Each Other Due to Portal and z-index Conflicts

A confirmation dialog opens on top of a details modal, and instead of stacking correctly on top, it renders behind it — or on top visually but unreachable by click, its buttons silently intercepted by the modal underneath. Every individual modal has a perfectly reasonable z-index; the two just never agreed on a shared frame of reference for what "on top" actually means.

Core JSC Team·September 26, 2026
UI/UXReactModalsCSSz-index

The Problem

An app opens a modal, and from within it, a second modal — a confirmation dialog, a nested form — is triggered. The second modal is supposed to appear stacked visually on top of the first. Instead, one of two things happens: it renders visually behind the first modal, partially or fully obscured, or it appears to render on top but its buttons don't respond to clicks, because pointer events are actually being captured by the first modal's own overlay sitting above it in the DOM stacking order. Both modals individually have sensible-looking z-index values in their own component's styles; the bug only appears when they're nested together.

Why It Happens

z-index only compares elements within the same stacking context — not globally across the whole page

A common misconception is that z-index values compare directly across the entire document. In reality, CSS stacking contexts are hierarchical: an element with position: relative and any z-index, a transform, an opacity less than 1, or several other properties creates a new stacking context, and z-index values inside it are only compared to siblings within that same context — not against elements in a completely different stacking context, no matter how high either value is set.

Modals rendered via React portals can end up nested inside each other's DOM stacking context rather than as true siblings

If a second modal's portal target is a container that lives inside the first modal's own rendered tree — rather than both portals mounting to the same top-level container, like document.body — the second modal is structurally nested inside the first modal's stacking context, and no z-index value assigned to it can escape that containment to render visually above a sibling context.

Different modal implementations across a codebase (a custom modal, a UI library's modal, a third-party dialog) can each manage their own portal target and z-index scheme inconsistently

When one part of the app uses a hand-rolled modal and another uses a component library's modal (Material UI, Ant Design, etc.), each system likely has its own opinion about where it portals to and what z-index range it reserves. Nesting one inside the other means nesting two systems that were never designed with each other's stacking assumptions in mind.

Pointer-events can appear correct visually while still being captured by the wrong element

Even when a z-index conflict makes the top modal visually correct, an overlay element from the underlying modal that still spans the full viewport (and hasn't had its pointer events disabled while covered) can silently intercept clicks meant for the modal that's supposed to be on top — producing an "it looks right but nothing responds" symptom distinct from the purely visual stacking bug.

The Fix

1. Portal every modal to the same single top-level container, not to nested locations

function Modal({ children }) {
  return createPortal(
    
{children}
, document.getElementById("modal-root") // always the same, top-level, sibling to #app ); }

Ensuring every modal in the app — regardless of which component triggered it or how deeply nested that trigger is in the React tree — portals to the exact same top-level DOM node makes all modals true siblings in the same stacking context, so a simple, consistent z-index ordering between them actually works as expected.

2. Manage stacking order explicitly with an incrementing counter rather than fixed z-index values per modal type

let topZIndex = 1000;

function useModalZIndex() {
  const [zIndex] = useState(() => ++topZIndex);
  return zIndex;
}

function Modal({ children }) {
  const zIndex = useModalZIndex();
  return createPortal(
    
{children}
, document.getElementById("modal-root") ); }

Assigning each newly opened modal the next value in a shared, incrementing counter — rather than a fixed z-index baked into each modal component's styles — guarantees a modal opened later always stacks above one opened earlier, regardless of how many modals happen to be nested at once.

3. Disable pointer events on an obscured modal's overlay rather than relying on z-index alone to prevent interaction

function Modal({ children, isTopmost }) {
  return createPortal(
    
{children}
, document.getElementById("modal-root") ); }

Explicitly setting pointer-events: none on any modal that isn't currently the topmost one removes the possibility of an underlying overlay silently capturing clicks meant for the modal actually on top, addressing the "looks right, doesn't respond" symptom directly rather than hoping z-index alone prevents it.

4. Standardize on a single modal implementation, or explicitly bridge stacking contexts when mixing libraries is unavoidable

// If a third-party library's modal can't be avoided, check its docs for a
// container/portal-target prop and point it at the same shared modal root

  {/* ... */}

Where a mix of modal implementations genuinely can't be avoided, explicitly configuring each one's portal target to the same shared container — most modal libraries expose this as a prop — brings them into the same stacking context deliberately, rather than leaving each one's default behavior to collide unpredictably.

Why This Works

Each fix addresses a different layer of the same root issue: stacking context boundaries that weren't accounted for when nested modals were first built. Portaling everything to one shared root eliminates accidental nested stacking contexts entirely; a shared incrementing z-index counter makes ordering deterministic regardless of how many modals are open; disabling pointer events on obscured overlays fixes the click-interception symptom directly; and explicitly bridging third-party modal libraries to a shared root prevents mismatched stacking assumptions between independently built systems.

Conclusion

Nested modals rendering behind each other or intercepting the wrong clicks isn't a z-index value being wrong in isolation — it's a stacking context boundary that wasn't accounted for, usually because two modals portal to different containers or belong to differently authored systems. Portal every modal to the same shared top-level container, manage stacking order with a shared incrementing counter rather than fixed per-modal values, disable pointer events on any overlay that isn't currently topmost, and explicitly bridge stacking contexts when mixing modal implementations is genuinely unavoidable.