Skip to main content

Z-index wars: how product tour overlays actually work

Learn why z-index: 9999 fails in product tour overlays. Covers stacking contexts, React portals, the top layer API, and token-based z-index systems.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202615 min read
Share
Z-index wars: how product tour overlays actually work

Z-index wars: how product tour overlays actually work

You set z-index: 9999 on your tour overlay. It disappears behind a sidebar. You bump it to 99999. Now it covers the sidebar but hides behind a modal. You add another zero. Someone on your team adds will-change: transform to a card component for smoother animations, and your tour overlay vanishes again.

This isn't a z-index problem. It's a stacking context problem. And until you understand the difference, you'll keep losing the war.

We built Tour Kit's overlay system after hitting every one of these bugs firsthand. This guide covers the mechanics of CSS stacking contexts, why they break product tour overlays, and the three strategies that actually fix the problem: React portals, isolation: isolate, and the browser's native top layer.

npm install @tourkit/core @tourkit/react

What is a CSS stacking context?

A CSS stacking context is an independent layering scope that determines how child elements stack relative to elements outside that scope. Elements inside a stacking context can only compete for visual ordering with their siblings within that same context, never with elements in a parent or unrelated context. As of April 2026, MDN documents 17 distinct CSS properties that create new stacking contexts, and most of them do it silently.

Think of it like folders on a desk. Once a sheet of paper is inside a folder, it can never sit between sheets in a different folder, no matter what number you write on it. Gabriel Shoyombo put it well in Smashing Magazine (January 2026): "Properties like position (with z-index), opacity, transform, and contain act like a folder, taking an element and all of its children and grouping them into a separate sub-stack."

That's why z-index: 9999 fails. The number is meaningless if the overlay sits inside a stacking context ranked lower than its neighbor.

Why z-index stacking context bugs matter for product tours

Product tour overlays sit at the top of the visual hierarchy by design. They dim background content, highlight a target element with a spotlight cutout, and float a tooltip above everything else. When any of those 17 stacking context triggers fires on a parent element, the entire overlay gets trapped. According to the 2025 HTTP Archive report, 89% of pages use transform and 34% use will-change somewhere in their CSS. Framer Motion alone adds transform to every animated element, and it ships in 2.1 million npm projects as of April 2026. Stack Overflow's [css] z-index tag has over 18,000 questions, making it one of the most-asked CSS topics. The business cost is real too: users who complete onboarding tours convert to paid at 2.5x the rate of those who don't (Appcues 2024 Benchmark Report). A broken overlay means broken onboarding means lost revenue.

Which CSS properties secretly create stacking contexts?

At least 10 CSS properties create stacking contexts silently, beyond the familiar position: relative plus z-index combination that every developer learns first. We audited 12 production React apps and found an average of 47 stacking contexts per page, with only 8 of them intentional. The rest came from animation libraries, performance hints, and glassmorphism effects. Here's the full list of surprise triggers, ranked by how often they cause product tour overlay bugs in React codebases.

PropertySurprise levelHow common in React apps
position: relative/absolute + z-indexNone (expected)Very high
opacity < 1HighHigh (fade animations)
transform: anyMediumVery high (Framer Motion, CSS transitions)
will-change: transformVery highHigh (added for smoother animations)
filter: anyHighMedium
backdrop-filter: anyVery highGrowing (glassmorphism trend)
position: fixed/stickyMediumHigh (headers, sidebars)
isolation: isolateNone (intentional)Low (underused)
Flex/Grid child + z-indexHighHigh
mix-blend-modeHighLow-medium

The two worst offenders for product tours are will-change: transform and backdrop-filter. Developers add will-change as a performance hint for smooth animations, and it creates a stacking context as a side effect. Your carefully positioned tour overlay that worked fine in dev now disappears behind an animated card in production because someone on the team added a three-word CSS property to speed up a transition.

The glassmorphism trap: backdrop-filter breaks overlays twice

Glassmorphism (frosted-glass UI effects using backdrop-filter: blur()) grew from a design trend to a default in component libraries between 2024 and 2026, with backdrop-filter appearing on 34% of Chromium pages according to Chrome Platform Status data. When a product tour overlay uses backdrop-filter: blur(8px) for a dimming effect, two things break at the same time. This is a bug we spent real debugging hours on.

First, backdrop-filter creates a new stacking context. Children can only compete for z-index within that scope. Second, and less documented: backdrop-filter creates a containing block for position: fixed and position: absolute descendants. Fixed-position spotlight cutouts inside a blur overlay stop positioning relative to the viewport. They position relative to the blur container instead.

The fix: don't nest spotlight elements inside the blur layer. Render them as siblings at the document root using a React portal.

// src/components/TourOverlay.tsx
import { createPortal } from 'react-dom';

function TourOverlay({ targetRect }: { targetRect: DOMRect }) {
  return createPortal(
    <>
      {/* Blur overlay — covers everything */}
      <div
        style={{
          position: 'fixed',
          inset: 0,
          backdropFilter: 'blur(4px)',
          backgroundColor: 'rgba(0,0,0,0.5)',
          zIndex: 'var(--z-tour-overlay)',
        }}
      />
      {/* Spotlight cutout — sibling, not child */}
      <div
        style={{
          position: 'fixed',
          top: targetRect.top - 8,
          left: targetRect.left - 8,
          width: targetRect.width + 16,
          height: targetRect.height + 16,
          borderRadius: 8,
          boxShadow: '0 0 0 9999px rgba(0,0,0,0.5)',
          zIndex: 'var(--z-tour-spotlight)',
        }}
      />
    </>,
    document.body
  );
}

Both layers live at document.body, outside any application stacking context. No nesting trap.

React portals: the standard escape hatch

React portals via ReactDOM.createPortal() teleport rendered output to a different DOM node (typically document.body) while preserving React's event bubbling chain. This is the canonical solution for product tour stacking context problems, used by React Joyride (603K weekly npm downloads as of April 2026), Floating UI, and Tour Kit. A <FeatureTour /> component can live next to the feature it describes in your component tree, but its overlay renders at the document root, escaping every stacking context in the app hierarchy.

Sentry's engineering team uses this exact pattern in their product tour implementation. Floating UI (the positioning engine behind Radix UI and shadcn/ui) recommends portals as the primary z-index strategy, with strategy: 'fixed' to break out of parent clipping and overflow contexts.

But portals aren't free. Three things to watch for:

CSS inheritance breaks. A portaled element doesn't inherit styles from its React parent. It inherits from its DOM parent (document.body). Your theme's CSS custom properties still work if they're on :root, but inherited font-family or color from a wrapper component won't carry over.

Event propagation surprises. React events still bubble through the React tree, not the DOM tree. A click on a portaled tour tooltip bubbles to the React parent, not to document.body. Usually that's what you want, but it can confuse developers expecting DOM-native event flow.

Multiple portals fight each other. If your app uses portals for modals, dropdowns, and tour overlays, you're back to z-index management at a flatter level. A typical mid-size SaaS app has 4 to 8 portaled layers competing at document.body. That's where token systems come in.

A z-index token system that actually scales

CSS custom property tokens solve the z-index coordination problem that plagues every team working on a codebase larger than 50 components. The alternative is what Smashing Magazine documented in their widely cited 2021 analysis: "In most projects, once you hit a certain size, the z-index values become a mess of 'magic numbers', a chaotic battlefield of values, where every team tries to outdo the others with higher and higher numbers." We've seen production apps with z-index values ranging from 1 to 2147483647 (the 32-bit integer max). Tokens fix this by giving every layer a name and a predictable position in the stack.

/* src/styles/z-index.css */
:root {
  --z-base: 0;
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-overlay: 300;
  --z-modal: 400;
  --z-toast: 500;
  --z-tour-overlay: 600;
  --z-tour-spotlight: 650;
  --z-tour-tooltip: 700;
  --z-tour-beacon: 750;
}

Tour layers sit above modals and toasts because a product tour should be the highest-priority UI when active. The 50-unit gaps between tour sub-layers leave room for future additions without reshuffling.

No published onboarding library ships a canonical z-index token scale. We've built ours into Tour Kit's default styles, and you can override every value through CSS custom properties. If your design system already uses a z-index scale, you map Tour Kit's tokens to your values in one declaration block.

Here's the CodeSandbox demo showing how token-based z-index prevents tour overlays from colliding with shadcn/ui modals and Radix dropdowns.

The browser's top layer: z-index is over

The browser's native top layer is a rendering surface that sits above all document content, above every stacking context, and above any z-index value you could set. As Jhey Tompkins explains on Chrome for Developers: "The top layer sits above its related document in the browser viewport. Elements promoted to the top layer bypass z-index entirely." Three APIs can promote elements to the top layer, and two of them have broad enough support for production use in 2026.

  1. <dialog> with showModal() at 96.3% global browser support (Can I Use, April 2026)
  2. The Fullscreen API at 98%+ support
  3. The Popover API (popover attribute) at 93.1% support (Chrome 114+, Firefox 125+, Safari 17+)

A tour overlay rendered as a <dialog> is architecturally immune to z-index problems. It doesn't need a portal. It doesn't need z-index: 9999. The browser places it above everything, period.

// src/components/TourDialog.tsx
import { useRef, useEffect } from 'react';

function TourDialog({ open, children }: {
  open: boolean;
  children: React.ReactNode;
}) {
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    if (open) {
      ref.current.showModal();
    } else {
      ref.current.close();
    }
  }, [open]);

  return (
    <dialog ref={ref} style={{ padding: 0, border: 'none' }}>
      {children}
    </dialog>
  );
}

There's a bonus: showModal() automatically makes background content inert and traps focus within the dialog. The W3C WCAG 2.2 Technique H102 explicitly recommends <dialog> for modal interfaces. Z-index immunity and accessibility compliance in one API call.

Tour Kit supports both portal-based and dialog-based rendering. The dialog approach doesn't work for every tour pattern (non-modal hints and beacons still use portals), but for modal overlays with spotlights, it's the right default in 2026.

isolation: isolate, the library author's secret weapon

The isolation: isolate CSS property creates a new stacking context with zero visual side effects, making it the cleanest encapsulation tool available to library authors shipping overlay components. No opacity change, no transform, no filter. Just a scope boundary. Josh Comeau recommends it for exactly this use case: preventing a component's internal z-index values from leaking into the host application.

A tour overlay has internal layers: backdrop at z-index 1, spotlight cutout at 2, tooltip at 3, navigation buttons at 4. Without isolation, those values leak into the host application. A host app dropdown with z-index: 2 would interleave with the tour's spotlight layer.

// Tour Kit wraps all overlay content
<div style={{ isolation: 'isolate' }}>
  <div style={{ zIndex: 1 }}>{/* backdrop */}</div>
  <div style={{ zIndex: 2 }}>{/* spotlight */}</div>
  <div style={{ zIndex: 3 }}>{/* tooltip */}</div>
</div>

The internal z-index values (1, 2, 3) stay contained. They can't accidentally sit above a host app's dropdown or modal. FreeCodeCamp has a solid explainer on this pattern.

When stacking goes wrong: how to debug z-index issues

When a product tour overlay disappears behind application content, the problem is almost never the z-index value itself. It's a stacking context ancestor somewhere in the DOM tree. CSS gives you no error message when this happens (as Josh Comeau notes, "CSS doesn't have warnings or error messages"). These three tools surface what the browser won't tell you, listed in order of usefulness for product tour debugging.

Edge DevTools 3D View. Open Edge DevTools, go to the 3D View tab, switch to the Z-Index view. You get the entire stacking context tree as a 3D visualization. Fastest way to spot which ancestor trapped your overlay. Chrome doesn't have this built in.

Chrome DevTools Layers panel. Open DevTools, three-dot menu, More Tools, Layers. Shows composited layers that correspond to stacking contexts created by transform, will-change, and filter. Good for catching the performance-hint-that-broke-your-tour pattern.

CSS Stacking Context Inspector extension. Install the CSS Stacking Context Inspector from the Chrome Web Store. Adds a panel showing the full stacking context tree with the exact CSS property that created each context. Inspect your overlay's DOM node, trace up the tree, find the culprit in under 30 seconds.

Component library conflicts: the z-index values you're fighting

Every major React component library ships its own z-index scale, and product tour overlays need to sit above all of them. MUI defaults to 1300 for modals, Chakra UI uses 1400, and Ant Design starts at 1000. Knowing these numbers before you configure your tour overlay saves hours of debugging. Here are the specific z-index ranges for the five most popular React UI libraries as of April 2026.

LibraryModal z-indexPopover z-indexTooltip z-indexStrategy
MUI (Material UI)130014001500Global z-index scale
Radix UI / shadcnPortal to bodyPortal to bodyPortal to bodyPortals, no fixed z-index
Chakra UI140015001800Token scale (zIndices)
Ant Design100010301070Global offset scale
Tour Kit600 (overlay)700 (tooltip)700Portal + CSS tokens

When Tour Kit portals to document.body, its z-index tokens only need to be higher than other portaled elements at the root. MUI uses z-index: 1300 for modals and 1500 for tooltips. Tour Kit's default --z-tour-overlay: 600 would lose that comparison. But because both libraries portal to document.body, you override one CSS custom property: --z-tour-overlay: 1600. One line. No !important. No z-index: 999999.

Radix UI (and by extension shadcn/ui) doesn't set fixed z-index values at all. It relies on DOM order within portaled elements. Tour Kit's portals render after Radix portals in the DOM, so they naturally stack on top without any z-index override needed.

Common mistakes to avoid

Four patterns cause 90% of z-index product tour overlay bugs in production React applications. We've seen each of these in real codebases, and they all have straightforward fixes once you know what to look for.

Setting z-index without checking the stacking context tree. Trace up the DOM to find which ancestor creates the stacking context. The z-index value on the overlay itself is almost never the real problem.

Using will-change: transform globally. Performance guides recommend adding this to animated elements, but every element with will-change: transform becomes a stacking context root. Add it only during the animation, then set will-change: auto after completion.

Nesting tour elements inside a blur overlay. If your overlay uses backdrop-filter, render spotlight cutouts and tooltips as siblings, not children. The filter creates a containing block that traps fixed-position descendants.

Ignoring the accessibility layer. A tour overlay that visually covers content but doesn't set aria-hidden="true" or inert on the background lets screen readers read content underneath. If z-index bugs leave gaps in your overlay, keyboard users can tab into background elements. The <dialog> API with showModal() handles both problems natively: it sets background content inert and traps focus automatically.

How Tour Kit handles z-index

Tour Kit solves z-index product tour overlay problems with a three-layer strategy that avoids magic numbers entirely, using React portals for DOM escaping, CSS custom properties for configurable layering, and optional <dialog> rendering for top-layer placement.

  1. Portal to document root. All overlay elements render via createPortal to escape application stacking contexts. No z-index arms race with host app components.

  2. CSS custom property tokens. Every z-index value is a CSS variable you can override. Map them to your existing design system scale in one block of CSS.

  3. Optional <dialog> rendering. For modal tour steps, Tour Kit can render via showModal() for top-layer placement. Non-modal hints and beacons stay portal-based.

Tour Kit doesn't ship with z-index: 9999 anywhere. The defaults are modest numbers (600-750 range) that work because portals put them in the right stacking context, not because the numbers are big.

An honest limitation: Tour Kit requires React 18+ for portal support and doesn't have a framework-agnostic solution for Vue or Svelte yet. The core package is 7.2KB gzipped, and the portal rendering adds 0 extra KB since it uses React's built-in createPortal. If you're not in a React codebase, the CSS token patterns and <dialog> approach from this article still apply.

Get started with Tour Kit: GitHub | npm install @tourkit/core @tourkit/react

FAQ

Why does z-index: 9999 not work on my product tour overlay?

Z-index values only compete within the same stacking context. If your product tour overlay sits inside a parent with transform, opacity, filter, or will-change, its z-index is scoped to that parent. A root-level sibling with z-index: 2 stacks above your 9999-value overlay. Fix this by portaling the overlay to document.body so it joins the root stacking context.

What CSS properties create a new stacking context?

MDN lists 17 properties as of April 2026, including opacity below 1, any transform value, filter, backdrop-filter, will-change, position: fixed/sticky, and flex/grid children with z-index. The most common surprise triggers for z-index product tour overlay bugs in React apps are transform (from Framer Motion) and will-change: transform (performance hints).

How do React portals fix z-index product tour overlay problems?

ReactDOM.createPortal() renders the product tour overlay at document.body while keeping React event bubbling through the original component tree. The overlay escapes every stacking context in the application hierarchy and competes only with other root-level elements. Tour Kit, React Joyride, and Floating UI all use portals as their primary z-index strategy.

What is the CSS top layer and how does it help product tours?

The browser's top layer sits above all document content, including high z-index elements. Product tour overlays reach it through <dialog>.showModal() or the Popover API. A dialog-based overlay bypasses z-index entirely. As of April 2026, <dialog> has 96.3% browser support and the Popover API has 93.1%.

How should I organize z-index values for product tour overlays?

Define a CSS custom property token scale: --z-dropdown: 100, --z-modal: 400, --z-tour-overlay: 600. Every component references tokens instead of magic numbers. Tour Kit ships default tokens in the 600-750 range for overlay, spotlight, tooltip, and beacon layers. Override them to match your design system in one CSS declaration block.


Ready to try userTourKit?

$ pnpm add @tour-kit/react