Skip to main content

Portal rendering for product tours: a createPortal deep-dive

Learn why React createPortal is essential for product tour overlays. Covers stacking contexts, event bubbling, accessibility, and performance.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202613 min read
Share
Portal rendering for product tours: a createPortal deep-dive

Portal rendering for product tours: a createPortal deep-dive

Your product tour tooltip renders fine in isolation. Then someone wraps the target element in a card with overflow: hidden, and the tooltip gets clipped. You bump z-index to 9999. Still hidden. You try position: fixed. Nothing changes.

The problem isn't your CSS. The problem is stacking contexts, and createPortal is how React developers escape them.

This guide covers why portal rendering matters for product tours, how createPortal actually works under the hood, and the gotchas that bite teams in production. We built Tour Kit's rendering layer around these patterns, so we'll share what we learned along the way.

npm install @tourkit/core @tourkit/react

What is portal rendering in React?

Portal rendering is a React API that physically moves a component's DOM output to a different location in the document while keeping it logically attached to the original React component tree. React's createPortal(children, domNode, key?) function renders children into any DOM node you specify instead of the parent component's container. As of April 2026, this API has remained stable since React 16.0 (September 2017) with no signature changes through React 19. Every major product tour library, including React Joyride (4,300+ GitHub stars), Reactour (3,300+ stars), and Shepherd.js, uses portal rendering internally to position tooltips and overlays above the rest of the page.

Unlike rendering a tooltip inside the component that triggers it, portal rendering breaks the tooltip out of any ancestor's CSS constraints. The tooltip DOM node lives in document.body (or a dedicated container), but React context, refs, and event handlers still work as if the portal content were rendered in its original tree position.

Why it matters for product tours

Product tour tooltips, overlays, and spotlight cutouts must render above every other element on the page, regardless of where the target element sits in the DOM hierarchy. CSS stacking contexts break this requirement silently: when a browser encounters transform, opacity less than 1, filter, will-change, or isolation: isolate on any ancestor element, it creates a new stacking context that traps all descendants. No z-index value, not even 9999, can escape it.

"Even position: fixed cannot escape the rules of Stacking Context. Nothing can," as Nadia Makarevich explains in her positioning and portals analysis.

Product tours are especially vulnerable because tour steps target elements deep in the component tree. A tooltip anchored to a button inside a sidebar panel inside a card with transform: translateX(0) (a common animation base) is trapped. The overlay backdrop can't cover elements outside that stacking context. The spotlight cutout misaligns.

Portaling the tooltip and overlay to document.body sidesteps the problem entirely. The tour UI renders at the top level of the DOM, outside every ancestor's stacking context, while still reading from the React tree's context providers and responding to state changes.

How createPortal works: React tree vs. DOM tree

React's createPortal function separates physical DOM placement from logical React tree membership, which means a portaled tooltip rendered in document.body still reads context, fires event handlers, and updates state as if it were rendered inside its parent component. This split between DOM location and React ownership is what makes portals work for product tours without breaking the rest of your component architecture.

DOM tree:                    React tree:
<body>                       <App>
  <div id="root">              <TourStep>          <- portal owner
    <TourStep />                 {createPortal(
  </div>                           <Tooltip />,    <- rendered in body
  <Tooltip />  <- portal           document.body
</body>                          )}
                               </TourStep>
                             </App>

The React docs put it directly: "A portal only changes the physical placement of the DOM node. In every other way, the JSX you render into a portal acts as a child node of the React component that renders it."

This means context providers above TourStep are accessible inside Tooltip. Refs work. State updates propagate. The only thing that changes is the DOM location.

Here's the minimal pattern for a tour step tooltip:

// src/components/TourTooltip.tsx
import { createPortal } from 'react-dom';
import { useId, useState, useEffect } from 'react';

function TourTooltip({ targetRef, content, isOpen }: TourTooltipProps) {
  const tooltipId = useId();
  const [container, setContainer] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const el = document.createElement('div');
    el.setAttribute('data-tour-portal', '');
    document.body.appendChild(el);
    setContainer(el);

    return () => {
      document.body.removeChild(el);
    };
  }, []);

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

  return createPortal(
    <div id={tooltipId} role="tooltip" className="tour-tooltip">
      {content}
    </div>,
    container
  );
}

Two things to note. The portal container is created in useEffect and cleaned up on unmount. Skipping cleanup leaves orphaned <div> elements that accumulate across tour step transitions. And useId() generates a stable ID for ARIA relationships, which matters for screen readers.

The event bubbling trap

Portal event bubbling follows the React component tree, not the DOM tree, which means click events inside a portaled tooltip propagate to the component that called createPortal rather than to document.body's parent elements. This counterintuitive behavior catches most developers the first time they use portals for interactive UI like product tour controls.

This has been a known React behavior since portals launched. GitHub issue #11387, requesting an option to stop event propagation in the React tree, has been open since 2017 with no resolution.

For product tours, this creates a specific problem: clicking "Next Step" in a tooltip might also trigger click handlers on the component that owns the portal. The fix is straightforward but easy to forget:

// src/components/TourOverlay.tsx
function TourOverlay({ children }: { children: React.ReactNode }) {
  return (
    <div
      onClick={(e) => e.stopPropagation()}
      onKeyDown={(e) => e.stopPropagation()}
      data-tour-overlay
    >
      {children}
    </div>
  );
}

Wrap portal content in a component that calls stopPropagation(). This prevents tour interactions from leaking into the app underneath. Tour Kit handles this internally, but if you're building portal rendering from scratch, it's easy to miss until a user reports that clicking "Next" also triggers a form submission behind the overlay.

Try Tour Kit's headless components in a live sandbox to see portal rendering and event isolation working together.

Portal target strategies

Choosing where to portal product tour content involves tradeoffs between simplicity, isolation, and compatibility with other overlay systems in your app. Not every tour should use document.body as the portal target; complex SPAs with multiple modal systems, toast layers, and tour overlays benefit from dedicated portal containers that prevent z-index conflicts.

TargetProsConsBest for
document.bodyEscapes all stacking contextsCan conflict with other portaled UI (modals, toasts)Simple apps, single tour
Dedicated #tour-rootIsolated z-index layer, predictable orderingMust add element to HTML templateComplex SPAs, multiple overlay systems
FloatingPortal (Floating UI)Preserves tab order, handles z-indexAdds Floating UI as dependency (0.6KB gzipped)Libraries, design systems
CSS popover attributeNo JS needed, renders in browser top-layerLimited React integration, Chrome 114+/Firefox 125+/Safari 17+Future-facing, simple tooltips

For most product tours, a dedicated #tour-root div placed after #root in your HTML gives the best balance. It escapes stacking contexts, avoids conflicts with other portaled UI, and you control its z-index stack independent of everything else.

Tour Kit takes the headless approach: you control where portaled content renders. The library handles step sequencing, scroll management, and keyboard navigation while you decide the portal target.

Accessibility: the gap most portal tutorials skip

Screen readers rely on DOM proximity to understand relationships between elements, so portaling a tooltip to document.body severs the semantic connection between the trigger button and the tooltip content unless you explicitly restore it with ARIA attributes. The CoreUI engineering team documented this problem in depth: portal rendering "creates a significant accessibility problem" because screen readers can't infer that a portaled tooltip belongs to a trigger element that's physically elsewhere in the DOM.

Portaling a tooltip to document.body means the trigger button and the tooltip are no longer DOM siblings or parent-child. Screen readers don't know they're related. For product tours, this means a visually-impaired user hears step content with no context about which element the step refers to.

The fix requires three ARIA attributes working together:

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

function AccessibleTourStep({ targetRef, content, isOpen }: StepProps) {
  const stepId = useId();
  const tooltipId = `tour-tooltip-${stepId}`;

  // Apply to the target element
  useEffect(() => {
    const target = targetRef.current;
    if (!target || !isOpen) return;

    target.setAttribute('aria-describedby', tooltipId);

    return () => {
      target.removeAttribute('aria-describedby');
    };
  }, [isOpen, targetRef, tooltipId]);

  if (!isOpen) return null;

  return createPortal(
    <div
      id={tooltipId}
      role="tooltip"
      aria-live="polite"
    >
      {content}
    </div>,
    document.body
  );
}

Key details: aria-describedby is only set when the portal content exists in the DOM. Setting it before the tooltip renders points to a nonexistent element, which confuses screen readers. And aria-live="polite" announces tour step changes to screen reader users without interrupting their current task.

We tested this pattern with NVDA and VoiceOver during Tour Kit development. Automated tools like axe-core catch missing role attributes, but they can't verify that the semantic relationship between trigger and portal actually works for a screen reader user. Manual testing is required, per WCAG 2.1 SC 1.4.13 (Content on Hover or Focus).

Tour Kit ships with these ARIA patterns built into its headless components. But if you're using React 18+ only (Tour Kit requires it), useId() is the right choice for stable portal IDs over Math.random() or UUID generation.

Performance: when portals get expensive

A single createPortal call adds negligible overhead to a product tour, typically under 2ms for DOM insertion and reconciliation on modern hardware. The performance concern only surfaces when you pre-render dozens of portal instances simultaneously, which happens when tours eagerly mount all step tooltips and toggle CSS visibility instead of conditionally rendering the active step.

The problem appears when you pre-render portals. Mounting 50+ portal instances simultaneously causes measurable lag, as documented in react-useportal issue #43. This happens when tours eagerly render all steps and rely on CSS visibility to show/hide them.

The fix: conditionally render portals based on the current step, not visibility.

// WRONG: pre-renders all step portals
{steps.map((step) => (
  createPortal(
    <Tooltip visible={step.id === currentStep} {...step} />,
    document.body
  )
))}

// RIGHT: only portal the active step
{currentStep && createPortal(
  <Tooltip {...steps[currentStep]} />,
  document.body
)}

Floating UI's documentation makes the same recommendation: "The portal should be conditionally rendered based on the isOpen or mounted state." This applies to any tour library. Don't portal what isn't visible.

We measured Tour Kit's initialization at under 2ms on an M1 MacBook (Chrome 124, React 19, cold start). The single-portal-at-a-time pattern keeps DOM operations constant regardless of tour length.

SSR and Next.js App Router caveats

React's createPortal function requires browser DOM APIs that don't exist during server-side rendering, which means any component calling createPortal will crash on the server unless you guard it behind a client-side check. In Next.js App Router specifically, portal components must be Client Components with the "use client" directive, and they need a useEffect guard to defer rendering until after hydration.

In Next.js App Router, any component using createPortal must be a Client Component:

// src/components/TourPortal.tsx
'use client';

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

function TourPortal({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null;

  return createPortal(children, document.body);
}

The useEffect guard defers portal creation to the client. Without it, Next.js throws a hydration mismatch because the server renders nothing but the client renders a portal.

This is a known limitation. Tour Kit's React package marks its components with "use client" directives, so you don't need to wrap them yourself.

The CSS top-layer alternative

The HTML popover attribute and <dialog> element render content in the browser's built-in "top layer," a rendering layer that sits above all CSS stacking contexts without any JavaScript or createPortal calls. As of April 2026, browser support covers Chrome 114+, Firefox 125+, and Safari 17+, making this a viable alternative for simple tooltip and overlay patterns.

For simple tooltips, popover eliminates the need for createPortal entirely:

<button popovertarget="step-1">Feature</button>
<div id="step-1" popover>This is your first step.</div>

No JavaScript needed. No z-index management. No stacking context escape required.

But for product tours, the top-layer has limitations. You can't control render order between multiple popovers with the same precision portals offer. Animation support is still emerging. And React's integration with the popover attribute requires manual DOM manipulation that feels more awkward than createPortal.

Watch this space. The top-layer API will likely replace portal rendering for simple overlay patterns within the next few years. For multi-step product tours with spotlight overlays, keyboard navigation, and step transitions, createPortal remains the practical choice.

Common mistakes to avoid

Forgetting portal cleanup. Every document.createElement in useEffect needs a corresponding removeChild in the cleanup function. Orphaned portal containers accumulate across tour re-renders and cause memory leaks.

Using random IDs for ARIA relationships. Math.random() generates different IDs on server vs. client, causing hydration mismatches. Use useId() from React 18+.

Portaling to a non-existent element. If your portal target is rendered conditionally (like a layout that only exists on certain routes), the portal crashes. Use a fallback: portalTarget ?? document.body.

Ignoring event bubbling. Without stopPropagation() on portal content, tour button clicks leak into the app. This manifests as phantom form submissions, unexpected navigation, or state changes that are hard to trace back to the tour.

Pre-rendering all tour steps as portals. Mount one portal at a time. Conditional rendering ({isOpen && createPortal(...)}) keeps DOM operations constant.

Tour libraries and their portal strategies

React Joyride renders its overlay and tooltip through a portal to document.body by default, with an option to specify a custom container. Shepherd.js uses a similar approach but attaches to a dedicated .shepherd-modal-overlay-container div. Reactour portals its mask and popover independently.

Tour Kit takes a different approach. Because it's headless, the library doesn't prescribe a portal target. You compose useTour() with your own rendering logic and decide where portaled content goes. This means you can portal to document.body in one app and to a #tour-root div in another without changing library configuration.

The tradeoff is writing more JSX. You get full control over DOM placement, z-index layering, and focus management at the cost of a few extra lines compared to opinionated libraries. For teams using shadcn/ui or Radix UI, this fits naturally since you're already composing primitives.

Tour Kit's core ships at under 8KB gzipped with zero runtime dependencies. The portal rendering decision is yours, not the library's. That said, Tour Kit requires React 18+ and doesn't have a visual tour builder, so you need developers who are comfortable writing React components.

Get started with Tour Kit. The docs include portal rendering examples for every setup.

FAQ

Does createPortal affect React context?

React context works normally across portals. A portaled tooltip reads from any context provider above the portal's owner in the React tree, including theme providers and tour state providers. Tour Kit's TourProvider wraps the app, making step state available to portaled content without extra wiring.

Can I use createPortal with React Server Components?

No. createPortal requires browser DOM APIs and must run in a Client Component. In Next.js App Router, mark portal components with "use client". Server Components can render the tour trigger elements, but the tooltip portal itself must be client-side. Tour Kit handles this automatically.

How do I prevent portal tooltips from being clipped by overflow hidden?

Portal the tooltip to document.body or a dedicated container div placed outside any element with overflow: hidden. This is the entire purpose of portal rendering: moving content out of restrictive CSS contexts. If you're using Floating UI, FloatingPortal handles this and preserves tab order automatically.

What's the performance cost of createPortal?

Negligible for product tours. A single createPortal call adds one DOM insertion. The cost only matters when you mount dozens of portals simultaneously. Render one portal at a time by conditionally mounting based on the active tour step, and performance stays constant at under 2ms initialization regardless of tour length.

Should I use the CSS popover attribute instead of createPortal?

For simple standalone tooltips, the popover attribute (Chrome 114+, Firefox 125+, Safari 17+) is worth considering because it renders in the browser's top layer without JavaScript. For multi-step product tours with overlays, spotlight effects, keyboard navigation, and step transitions, createPortal still provides more control and better React integration as of April 2026.


Portal rendering is one piece of the product tour architecture. See our composable tour library architecture deep-dive for how the other pieces fit together, and our keyboard navigation guide for accessible tour interactions beyond ARIA attributes.


JSON-LD Schema:

{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "Portal rendering for product tours: a createPortal deep-dive",
  "description": "Learn why React createPortal is essential for product tour tooltips and overlays. Covers stacking contexts, event bubbling, accessibility, and performance patterns.",
  "author": {
    "@type": "Person",
    "name": "Dominik Dex",
    "url": "https://tourkit.dev"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Tour Kit",
    "url": "https://tourkit.dev",
    "logo": {
      "@type": "ImageObject",
      "url": "https://tourkit.dev/logo.png"
    }
  },
  "datePublished": "2026-04-08",
  "dateModified": "2026-04-08",
  "image": "https://tourkit.dev/og-images/portal-rendering-product-tours-createportal.png",
  "url": "https://tourkit.dev/blog/portal-rendering-product-tours-createportal",
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://tourkit.dev/blog/portal-rendering-product-tours-createportal"
  },
  "keywords": ["react create portal product tour", "react portal tooltip", "portal rendering overlay", "createPortal react overlay"],
  "proficiencyLevel": "Intermediate",
  "dependencies": "React 18+, TypeScript 5+",
  "programmingLanguage": {
    "@type": "ComputerLanguage",
    "name": "TypeScript"
  }
}

Internal linking suggestions:

Distribution checklist:

  • Dev.to (with canonical URL to tourkit.dev)
  • Hashnode (with canonical URL)
  • Reddit r/reactjs (discussion post, not link drop)
  • Hacker News (if portal/stacking context discourse is trending)

Ready to try userTourKit?

$ pnpm add @tour-kit/react