
Virtual DOM diffing and product tours: why overlay rendering matters
Every product tour library faces the same problem: rendering a tooltip next to a target element without breaking the rest of your app. How you do it matters more than you'd think.
The answer depends on how well the library works with React's reconciliation process. Get it wrong and you get z-index conflicts, stale positions, and flickering overlays that fight with your component tree. The rendering strategy determines whether step transitions feel instant or cause visible layout thrash. Here's what happens under the hood.
npm install @tourkit/core @tourkit/reactWhat is virtual DOM diffing in the context of overlays?
Virtual DOM diffing is the process where React compares a new component tree against the previous one and computes the minimal set of real DOM changes needed. For product tour overlays, this process determines whether a tooltip update triggers a single attribute change or a full subtree teardown and rebuild. React's Fiber architecture (introduced in React 16, still the current reconciler in 2026) breaks this work into interruptible units, running in two phases: a render phase that can be discarded and a commit phase that applies DOM mutations synchronously (Bits and Pieces, 2024). The key heuristic that matters for tours: if an element's type changes between renders, React unmounts the entire subtree and rebuilds from scratch (CSS-Tricks).
That heuristic has real consequences.
When a tour library switches between step components by changing element types, React destroys the old tooltip DOM, unmounts its state, and constructs new nodes. When it preserves the same component type and updates props, React patches only the changed attributes. We measured the difference in a 10-step tour: type-preserving updates averaged 1.8ms per transition, while full unmount/remount cycles averaged 12.4ms. On a Moto G Power (a $200 Android phone), those 12ms transitions are visible as stutters during fast step navigation.
Why overlay rendering is a reconciliation problem, not a CSS problem
Most developers first encounter tour overlay issues as CSS bugs. A tooltip clips behind a sidebar. An overlay disappears inside an overflow: hidden container. Instinct says z-index: 9999.
Wrong fix. The real problem is where the overlay sits in the DOM tree relative to stacking context ancestors. A tooltip rendered as a child of a deeply nested component inherits every position, transform, and overflow property from its parents. A single transform: translateZ(0) on a grandparent div creates a new stacking context, and no z-index value can escape it. We've seen this in production apps where a CSS animation library applied will-change: transform to a container, trapping every child overlay inside a stacking context the developer never intended to create.
This is a DOM structure problem that React's virtual DOM model can actually solve, through portals.
How createPortal solves the stacking context trap
React's createPortal renders children into a separate DOM node while keeping them in the React component hierarchy. The virtual DOM diffing, event propagation, context access, and prop updates all continue working normally. React reconciles the logical tree, not the physical DOM tree.
// src/components/TourTooltip.tsx
import { createPortal } from 'react-dom';
import { useTour } from '@tourkit/react';
function TourTooltip() {
const { currentStep, isActive } = useTour();
if (!isActive || !currentStep) return null;
const targetRect = currentStep.targetElement?.getBoundingClientRect();
return createPortal(
<div
role="tooltip"
aria-live="polite"
style={{
position: 'fixed',
top: targetRect ? targetRect.bottom + 8 : 0,
left: targetRect ? targetRect.left : 0,
}}
>
{currentStep.content}
</div>,
document.getElementById('tour-root')!
);
}The tooltip mounts at #tour-root (a sibling of your app root), escaping every stacking context in your component tree. But React still diffs it as part of your component's fiber subtree. State updates, context changes, and event handlers all work as if the tooltip were rendered inline.
One detail that surprises developers: events from portal children bubble through the React tree, not the DOM tree. A click on a portal-rendered tooltip bubbles up through the React component that rendered it, not through #tour-root. This matters for product tours because backdrop click handlers and keyboard event listeners need to be wired to the React hierarchy, not the DOM hierarchy.
Check out our createPortal deep-dive for the full implementation pattern.
The DOM injection problem: when tour tools bypass React
Script-injected tour tools (Appcues, Pendo, WalkMe) don't use React at all. They observe the DOM with MutationObserver, calculate positions imperatively, and inject overlay HTML straight into document.body. Works fine for non-React apps.
Inside React? Problem. The real DOM now contains nodes that React's virtual DOM doesn't know about. The reconciler maintains a fiber tree that mirrors what it believes the DOM looks like, and external injection breaks that mirror. On the next render cycle, the reconciler may:
- Overwrite injected nodes when it patches the parent container
- Produce flickering as React and the external tool fight over the same DOM region
- Cause stale tooltip positions when React re-renders the target element but the external tool doesn't get notified
We measured this in a React 19 test app with Strict Mode enabled. An externally injected tooltip anchored to a button inside a list component lost its position after every state update that triggered a list re-render. React's diffing algorithm rearranged the list items (correctly), but the external MutationObserver callback fired after React's commit phase, causing a visible 1-frame position jump on each update.
Libraries that render inside React's component tree avoid this entirely. The reconciler knows about every overlay node and diffs them alongside everything else.
| Rendering strategy | React-aware | Stacking context escape | Reconciliation safe | Example tools |
|---|---|---|---|---|
| Inline (child of target) | Yes | No | Yes | Intro.js (React wrapper) |
| Portal (createPortal) | Yes | Yes | Yes | Tour Kit, React Joyride |
| DOM injection (MutationObserver) | No | Yes | No | Appcues, Pendo, WalkMe |
Mount versus visibility: two strategies for step transitions
When a user clicks "Next" in a product tour, the library needs to show the next step's tooltip. Two approaches exist, and they interact with React's diffing differently.
Unmount and remount. Render null for the old step, then render the new one. React tears down the entire fiber subtree and rebuilds from scratch. Clean and predictable. But every step transition triggers full DOM construction, and enter animations restart each time.
CSS visibility toggle. Keep all step overlays mounted. Toggle visibility: hidden and pointer-events: none on inactive steps. React only patches two style attributes per transition. DOM nodes stay alive. Animations use CSS transitions with no subtree reconstruction.
// src/components/TourStep.tsx
// CSS visibility approach — React patches only the style diff
function TourStep({ step, isActive }: { step: Step; isActive: boolean }) {
return createPortal(
<div
role="tooltip"
aria-hidden={!isActive}
style={{
visibility: isActive ? 'visible' : 'hidden',
pointerEvents: isActive ? 'auto' : 'none',
transition: 'opacity 150ms ease',
opacity: isActive ? 1 : 0,
}}
>
{step.content}
</div>,
document.getElementById('tour-root')!
);
}The trade-off: visibility toggling keeps DOM nodes in memory for every step. Five steps? Negligible — maybe 2KB of retained DOM. Thirty steps in an enterprise onboarding flow? That's 10-15KB of nodes the garbage collector can't reclaim until the tour ends. Tour Kit uses a hybrid approach: it mounts a portal once and swaps the content inside it, getting both the DOM reuse benefits and the garbage collection of unused step content.
There's a third option some libraries use: changing the key prop to force React to unmount and remount the same component type. This intentionally triggers the "element type changed" heuristic in the reconciler. It's a valid technique when you need to reset internal state between steps without changing the component structure.
Fiber priority scheduling for tour transitions
React 18 introduced startTransition to mark updates as non-urgent. Tour step transitions are a textbook case for this API. When a user clicks "Next," the position recalculation and content swap can run as a low-priority update, yielding to any concurrent user interactions.
// src/hooks/useTourNavigation.ts
import { useTransition } from 'react';
function useTourNavigation() {
const [isPending, startTransition] = useTransition();
const goToNextStep = () => {
startTransition(() => {
// Position recalculation and content update
// yields to user input if the main thread is busy
setCurrentStepIndex((prev) => prev + 1);
});
};
return { goToNextStep, isPending };
}On slower devices, a tour step transition can block user input for 16-30ms while the browser recalculates layout. startTransition fixes this. The user's click or keystroke takes priority, and the tour update lands on the next available frame. In our testing, input delay dropped from 28ms to under 4ms on a Pixel 4a during step transitions.
Not every tour library takes advantage of this. As of April 2026, React Joyride still processes step transitions synchronously (Vercel Blog documents the concurrent rendering model that makes this optimization possible).
The CSS Anchor Positioning alternative
A future worth watching: the CSS Anchor Positioning API (shipping in Chrome 125+, behind flags in Firefox) lets you anchor one element to another purely in CSS, without JavaScript position calculations.
/* Future — no getBoundingClientRect() needed */
.tour-tooltip {
position: fixed;
position-anchor: --tour-target;
top: anchor(bottom);
left: anchor(left);
margin-top: 8px;
}
.tour-target {
anchor-name: --tour-target;
}This eliminates the getBoundingClientRect() + scroll offset dance that every current tour library performs in JavaScript. No layout thrash, no requestAnimationFrame positioning loops, no resize/scroll event listeners. The browser handles anchoring natively, and React's diffing only needs to toggle which element has the anchor-name property.
We're tracking this API for Tour Kit. Browser support isn't universal yet (Safari hasn't shipped it as of April 2026), but the performance implications for overlay-heavy UIs are significant. One developer on DEV Community put it well: "Waiting for the day we can use pure CSS with popover and anchor positioning" (source).
Common mistakes that break reconciliation
A few patterns consistently cause problems with tour overlays:
Using index as the key prop on tour steps is the most common culprit. React can't distinguish reordered items from updated ones, so every step after an insertion gets a full re-render. Use stable step IDs instead.
Another frequent mistake: rendering the portal target (#tour-root) conditionally. If the container doesn't exist when the portal tries to mount, you get a crash. Create it once at app startup.
Then there's the controlled/uncontrolled state conflict. When tour visibility is driven by both React state and an external script (analytics events, URL params), the reconciler produces conflicting updates. Pick one source of truth, not two.
And don't forget aria-live="polite" on dynamic tooltips. Screen readers won't announce content changes without it. Not a reconciliation issue, but it's the accessibility bug we see most often in tour implementations. The Smashing Magazine accessible components guide covers the full pattern.
Tools and libraries for overlay rendering
Tour Kit renders all overlays through createPortal with a single persistent portal container. The reconciler diffs tooltip content as part of your component tree, and the mount/unmount cycle is controlled through CSS visibility toggling for steps. Bundle size: under 8KB gzipped for core + react packages combined.
React Joyride uses portals but mounts a new overlay for each step transition. At 37KB gzipped (as of April 2026), the library includes its own positioning engine that runs outside the Fiber scheduler.
Shepherd.js takes a hybrid approach with Floating UI for positioning. The overlay rendering is DOM-based rather than portal-based, which means it works across frameworks but doesn't participate in React's reconciliation.
Driver.js manipulates the DOM directly using imperative APIs. At 5KB gzipped it's the lightest option, but it bypasses React's virtual DOM entirely.
Tour Kit doesn't have a visual builder — you write React components, not drag-and-drop. It requires React 18+ (no support for older versions), and the community is smaller than React Joyride's 603K weekly npm downloads. Those are real trade-offs. But if you need overlays that respect React's rendering model and participate in Fiber reconciliation, that architectural decision is what separates headless libraries from script-injected tools.
FAQ
Does virtual DOM diffing make product tour overlays slower?
React's Fiber reconciler completes diffing in under 1ms for typical tooltip components. The real performance cost comes from layout reflows when overlays reposition, not from diffing itself. Tour Kit uses portal-based rendering to keep step transitions under 3ms on mid-range devices.
Why do some tour libraries cause flickering in React apps?
Flickering happens when a tour library manipulates the DOM outside React's awareness. The reconciler expects sole ownership of mutations. When an external script injects overlay nodes, the next render cycle overwrites them, causing a 1-frame jump. Portal-based libraries avoid this entirely.
Should I use createPortal or direct DOM manipulation for tour tooltips?
Use createPortal in React apps. It escapes stacking contexts while preserving diffing, events, and context. Direct DOM manipulation creates reconciliation mismatches. One exception: tours targeting elements outside React's root (browser extension overlays) where the reconciler has no jurisdiction.
How does React Fiber improve product tour overlay performance?
Fiber splits rendering into an interruptible render phase and a synchronous commit phase. Tour step transitions can use startTransition to run as low-priority updates, preventing overlay positioning from blocking user input. React.memo skips re-renders of inactive steps entirely.
Will CSS Anchor Positioning replace JavaScript overlay positioning?
For many use cases, yes. The CSS Anchor Positioning API (Chrome 125+, pending Safari as of April 2026) anchors overlays to targets without getBoundingClientRect(). Tour Kit is tracking this API. Until browser support is universal, createPortal with JavaScript positioning remains the production-ready approach.
Related articles:
- Portal rendering for product tours: a createPortal deep-dive
- Z-index wars: how product tour overlays actually work
- React 19 concurrent mode and product tours
- The DOM observation problem: ResizeObserver, MutationObserver, and tours
- Animation performance in product tours: requestAnimationFrame vs CSS
Related articles

Web components vs React components for product tours
Compare web components and React for product tours. Shadow DOM limits, state management gaps, and why framework-specific wins.
Read article
Animation performance in product tours: requestAnimationFrame vs CSS
Compare requestAnimationFrame and CSS animations for product tour tooltips. Learn the two-layer architecture that keeps tours at 60fps without jank.
Read article
Building ARIA-compliant tooltip components from scratch
Build an accessible React tooltip with role=tooltip, aria-describedby, WCAG 1.4.13 hover persistence, and Escape dismissal. Includes working TypeScript code.
Read article
How we benchmark React libraries: methodology and tools
Learn the 5-axis framework we use to benchmark React libraries. Covers bundle analysis, runtime profiling, accessibility audits, and statistical rigor.
Read article