Back to Journal
Architecture7 min read

Why Your z-index: 9999 is Broken (And How We Fixed It with React Portals)

Elena Rodriguez
Frontend Architect

The TL;DR

The Problem: You set your mobile drawer to z-index: 9999. It still renders behind the sticky header. You check the parent divs. Everything looks fine. Then you realize your beautiful backdrop-blur-md navigation just created an unbreakable CSS stacking context trap.

The Solution: Instead of removing the blur or restructuring the entire HTML tree, use React's createPortal to eject the drawer straight to document.body, bypassing all intermediate stacking contexts.

The Stacking Context Trap (Why it happens)

CSS is usually straightforward: elements lower in the DOM paint on top of elements higher up, and you can force them higher using z-index. But that's only true within the same Stacking Context.

When you apply properties like transform, opacity < 1, or filter (which includes Tailwind's backdrop-blur) to an element, the browser creates a brand new stacking context. Anything inside that element is trapped. A child with z-index: 999999 will still render underneath a sibling of the parent that has a higher natural stacking order.

The Hacky Workarounds (Why we rejected them)

When we built the Vantemo marketing site, our mobile navigation was trapped behind our premium glassmorphism header. We briefly considered the standard "fixes":

  1. Remove the blur: Unacceptable for our design system.
  2. Move the DOM elements: Decoupling the MobileDrawer component from the Header component ruins React's component encapsulation.

We needed a way to keep the logic co-located in the Header.tsx file while rendering the DOM node completely outside of it.

The React Portal Escape Hatch

React Portals provide a native way to render children into a DOM node that exists outside the DOM hierarchy of the parent component. Here is the exact, copy-pasteable <PortalOverlay> structure we use.

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';

export function MobileDrawer({ isOpen, onClose }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    // Optional: Lock body scroll here
  }, []);

  if (!mounted || !isOpen) return null;

  // Eject the drawer to document.body, bypassing all stacking contexts
  return createPortal(
    <div className="fixed inset-0 z-[9999]">
      <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
      <div className="absolute right-0 top-0 h-full w-4/5 bg-gray-900 shadow-2xl p-6">
        {/* Drawer content */}
      </div>
    </div>,
    document.body
  );
}

By forcing the portal to append to the body tag, the element returns to the root stacking context where z-index: 9999 actually means what it says. You preserve your beautiful aesthetic, keep your component logic entirely encapsulated, and permanently solve the stacking context trap.

Frequently Asked Questions

Does createPortal hurt performance?

No. React efficiently abstracts the event delegation. Event bubbling still works perfectly through the React tree, even though the DOM nodes are separated.

Does this affect SEO?

Since the content is rendered entirely via JavaScript and only upon interaction, it shouldn't contain critical indexable text. Ensure all primary navigation links are available elsewhere in standard HTML for crawlers.

Want to solve hard problems with us?

We're always looking for exceptional engineers to join the team.

View Open Roles