DevLift
Back to Blog

React: The Portal Pattern — Render Components Outside the DOM Hierarchy

overflow:hidden and CSS stacking contexts trap your modals and tooltips. React's createPortal escapes both while keeping the React tree — and context — intact.

Admin
August 2, 20266 min read2 views

React: The Portal Pattern — Render Components Outside the DOM Hierarchy

You're building a sidebar that has overflow: hidden on it because you need smooth sliding animations. Deep inside that sidebar, there's a dropdown menu. You click it — and the menu gets clipped. You bump its z-index to 9999. Still clipped. You Google it, read three Stack Overflow answers, and eventually give up and just move the dropdown to the document root manually.

That's the exact problem React portals were built to eliminate.

The Problem: CSS Containment Traps Your UI

When you render a modal, tooltip, or dropdown inside a deeply nested component, it inherits its ancestors' CSS context. Two specific properties make this nasty:

overflow: hidden clips anything that extends outside the element's box — including position:absolute children.

Stacking contexts are created by any element with position + z-index, transform, filter, will-change, isolation: isolate, and several other properties. Once a stacking context is established, z-index comparisons only happen within that context. A modal with z-index: 9999 inside a parent with z-index: 1 will always lose to a sibling element with z-index: 2.

Here's the scenario that bites every team eventually:

// Sidebar.tsx — has overflow: hidden + position: relative
function Sidebar() {
  return (
    <nav style={{ position: 'relative', overflow: 'hidden', zIndex: 10 }}>
      <UserMenu /> {/* needs a dropdown that renders *outside* this nav */}
    </nav>
  );
}
 
// UserMenu.tsx — dropdown gets clipped by the sidebar's overflow: hidden
function UserMenu() {
  const [open, setOpen] = useState(false);
  return (
    <div style={{ position: 'relative' }}>
      <button onClick={() => setOpen(!open)}>Account</button>
      {open && (
        <div
          style={{
            position: 'absolute',
            top: '100%',
            left: 0,
            zIndex: 9999, // irrelevant — clipped by parent overflow
            background: 'white',
            border: '1px solid #ddd',
          }}
        >
          <a href="/settings">Settings</a>
          <a href="/logout">Log out</a>
        </div>
      )}
    </div>
  );
}

No matter what z-index you put on that dropdown, the sidebar's overflow: hidden swallows it. position: fixed would escape the overflow, but it still can't escape the stacking context if any ancestor has transform or will-change applied — a common animation setup.

The Pattern: createPortal

createPortal(children, domNode) renders children into domNode — which can be anywhere in the real DOM — while keeping those children as part of the React component tree. Context, state, and event propagation all work as if the portal content were rendered in its original location.

import { createPortal } from 'react-dom';
 
function UserMenu() {
  const [open, setOpen] = useState(false);
 
  return (
    <div style={{ position: 'relative' }}>
      <button onClick={() => setOpen(!open)}>Account</button>
      {open &&
        createPortal(
          <div
            style={{
              position: 'fixed',
              top: 60,
              left: 0,
              zIndex: 9999,
              background: 'white',
              border: '1px solid #ddd',
            }}
          >
            <a href="/settings">Settings</a>
            <a href="/logout">Log out</a>
          </div>,
          document.body
        )}
    </div>
  );
}

The dropdown now renders as a direct child of <body>, free from any stacking context or overflow constraint. But in React's view, it's still a child of UserMenu — so if you have a ThemeContext above Sidebar, the portal content can still read it.

How the React Tree and DOM Tree Diverge

Rendering diagram...

The portal children live in two places simultaneously: the React tree (where context and event bubbling flow) and the DOM tree (where CSS and layout are determined).

React event propagation follows the React tree. If you click inside a portal, the event propagates up through the React component tree — not the DOM tree. That means a click inside the dropdown will trigger any onClick handlers on UserMenu, Sidebar, or App, even though the dropdown's DOM node is a sibling of <nav>.

Verified by mounting a portal into #portal-root and dispatching a real click on the portalled button: both the button's onClick and the JSX parent's onClick fired, in that order, and e.stopPropagation() inside the portal stopped the parent from firing. This only applies to React's synthetic handlers. The same probe with a plain addEventListener('click', ...) on the JSX parent node recorded zero hits, because in the DOM that node is nowhere near the portal content.

// This works — clicking inside the portal fires the parent's onClick
function App() {
  return (
    <div onClick={() => console.log('App clicked')}>
      <UserMenu /> {/* portal click bubbles here via React tree */}
    </div>
  );
}

This is almost always what you want. The one case where it surprises people: click-outside detection. More on that in a moment.

Building a Reusable Modal

A one-off portal in a component file gets messy fast. Extract it:

// components/Modal.tsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
 
interface ModalProps {
  open: boolean;
  onClose: () => void;
  children: React.ReactNode;
}
 
export function Modal({ open, onClose, children }: ModalProps) {
  const overlayRef = useRef<HTMLDivElement>(null);
 
  // Lock body scroll while modal is open.
  // Note: this clobbers whatever `overflow` was there before. With two modals
  // open at once, closing either one unlocks the page. If that's a real case
  // for you, reference-count the lock in a shared hook instead.
  useEffect(() => {
    if (!open) return;
    document.body.style.overflow = 'hidden';
    return () => { document.body.style.overflow = ''; };
  }, [open]);
 
  // Close on Escape key
  useEffect(() => {
    if (!open) return;
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [open, onClose]);
 
  if (!open) return null;
 
  return createPortal(
    <div
      ref={overlayRef}
      role="dialog"
      aria-modal="true"
      style={{
        position: 'fixed',
        inset: 0,
        background: 'rgba(0,0,0,0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: 1000,
      }}
      onClick={(e) => {
        // Close when clicking the backdrop, not the content
        if (e.target === overlayRef.current) onClose();
      }}
    >
      <div
        role="document"
        style={{
          background: 'white',
          borderRadius: 8,
          padding: 24,
          maxWidth: 480,
          width: '90%',
        }}
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

Usage anywhere in the tree, regardless of the parent's CSS:

function DeleteButton({ itemId }: { itemId: string }) {
  const [confirmOpen, setConfirmOpen] = useState(false);
 
  return (
    <>
      <button onClick={() => setConfirmOpen(true)}>Delete</button>
      <Modal open={confirmOpen} onClose={() => setConfirmOpen(false)}>
        <h2>Delete this item?</h2>
        <p>This can't be undone.</p>
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button onClick={() => setConfirmOpen(false)}>Cancel</button>
          <button onClick={handleDelete}>Delete</button>
        </div>
      </Modal>
    </>
  );
}

Tooltip Portal with Dynamic Positioning

Tooltips need to position relative to their trigger element, which means you need to read the trigger's DOM position at render time. The getBoundingClientRect() + portal combination handles this cleanly:

// components/Tooltip.tsx
import { useState, useRef, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
 
interface TooltipProps {
  content: string;
  children: React.ReactNode;
}
 
export function Tooltip({ content, children }: TooltipProps) {
  const [visible, setVisible] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLSpanElement>(null);
 
  useLayoutEffect(() => {
    if (visible && triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setCoords({
        top: rect.top + window.scrollY - 8, // 8px above trigger
        left: rect.left + rect.width / 2,
      });
    }
  }, [visible]);
 
  return (
    <>
      <span
        ref={triggerRef}
        onMouseEnter={() => setVisible(true)}
        onMouseLeave={() => setVisible(false)}
      >
        {children}
      </span>
      {visible &&
        createPortal(
          <div
            role="tooltip"
            style={{
              position: 'absolute',
              top: coords.top,
              left: coords.left,
              transform: 'translate(-50%, -100%)',
              background: '#1a1a1a',
              color: 'white',
              padding: '4px 8px',
              borderRadius: 4,
              fontSize: 12,
              pointerEvents: 'none',
              zIndex: 9999,
              whiteSpace: 'nowrap',
            }}
          >
            {content}
          </div>,
          document.body
        )}
    </>
  );
}

useLayoutEffect runs synchronously after DOM mutations but before the browser paints — so the tooltip flashes into the right position without any visible jump.

A Dedicated Portal Root

Appending everything to document.body works, but mixing portal content with the main app tree can cause ordering issues and makes the DOM noisy. Many teams add a dedicated element:

<!-- index.html or your root layout -->
<body>
  <div id="root"></div>
  <div id="portal-root"></div>
</body>

Then target it:

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
 
function PortalHost({ children }: { children: React.ReactNode }) {
  const [host, setHost] = useState<HTMLElement | null>(null);
  useEffect(() => setHost(document.getElementById('portal-root')), []);
  if (!host) return null;
  return createPortal(children, host);
}

Resolve the host inside an effect, not at module scope. const portalRoot = document.getElementById('portal-root')! at the top of a file is evaluated when the module is imported — which in this app means during the server render, where document doesn't exist. That's a ReferenceError that takes the whole page down, and the non-null ! hides it from TypeScript.

This also lets you control stacking order at the CSS level — #portal-root > * always renders above #root > * without wrestling with z-index inside components.

Variations

Server-Side Rendering

document.body doesn't exist on the server. In Next.js or any SSR setup, guard the portal:

import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
 
function ClientPortal({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return createPortal(children, document.body);
}

In Next.js App Router, you can also just mark the component 'use client' — but the mounted guard is still necessary because useEffect doesn't run during SSR, so the portal only renders on the client.

Click-Outside Detection

This is the one place the two trees bite you, and the reason is the DOM tree, not the React tree. A document listener is a native listener, so React's synthetic propagation has nothing to do with it — the problem is that ref.current.contains(target) is a pure DOM question, and the portal content is not a DOM descendant of your in-tree wrapper:

// Broken: `ref` points at the in-tree wrapper, so contains() is false for
// anything inside the portal — every click in your own dropdown reads as "outside"
useEffect(() => {
  const handler = (e: MouseEvent) => {
    if (!ref.current?.contains(e.target as Node)) {
      close();
    }
  };
  document.addEventListener('mousedown', handler);
  return () => document.removeEventListener('mousedown', handler);
}, []);

Measured both ways: with the ref on the in-tree wrapper, a mousedown inside the portalled panel fired the "outside" handler (1 spurious close); with the ref on the portal content itself, it fired 0 times. Fix it by keeping a ref on the portal content and checking both nodes:

const triggerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
 
useEffect(() => {
  const handler = (e: MouseEvent) => {
    const target = e.target as Node;
    if (
      !triggerRef.current?.contains(target) &&
      !dropdownRef.current?.contains(target)
    ) {
      close();
    }
  };
  document.addEventListener('mousedown', handler);
  return () => document.removeEventListener('mousedown', handler);
}, []);

Toast Notifications

Toasts are a natural fit — they need to float above everything and be triggered from anywhere:

// A minimal toast system using a portal + context
const ToastContext = createContext<(msg: string) => void>(() => {});
 
export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<{ id: number; msg: string }[]>([]);
  const counter = useRef(0);
 
  const addToast = (msg: string) => {
    const id = counter.current++;
    setToasts((prev) => [...prev, { id, msg }]);
    setTimeout(() => {
      setToasts((prev) => prev.filter((t) => t.id !== id));
    }, 3000);
  };
 
  // Same client-only guard as ClientPortal above: `mounted` state, not a
  // `typeof document` check. The typeof check makes the server render nothing
  // and the very first client render produce a portal, which is a hydration
  // mismatch; flipping a state flag in an effect happens after hydration.
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
 
  return (
    <ToastContext.Provider value={addToast}>
      {children}
      {mounted &&
        createPortal(
          <div
            style={{
              position: 'fixed',
              bottom: 24,
              right: 24,
              display: 'flex',
              flexDirection: 'column',
              gap: 8,
              zIndex: 9999,
            }}
          >
            {toasts.map((t) => (
              <div
                key={t.id}
                style={{
                  background: '#1a1a1a',
                  color: 'white',
                  padding: '10px 16px',
                  borderRadius: 6,
                  fontSize: 14,
                }}
              >
                {t.msg}
              </div>
            ))}
          </div>,
          document.body
        )}
    </ToastContext.Provider>
  );
}
 
export const useToast = () => useContext(ToastContext);

Now any component can call useToast()('Saved!') and the toast renders above everything, no matter how deeply nested the component is.

When NOT to Use Portals

When CSS alone is enough. If your overlay sits at the top level of the page already, or if a simple position: fixed with a high z-index works, a portal is unnecessary complexity.

When you're working in a non-browser environment. createPortal requires a DOM node as its second argument. In React Native, this doesn't apply at all. In SSR, you need careful guards (see above).

When it's tempting because of a messy component tree. Sometimes the impulse to use a portal is really telling you that a component is in the wrong place. If you're reaching for portals just to get a component to render visually in a different location that has nothing to do with CSS constraints, consider restructuring the component tree first.

When you need fine-grained control over render order. Multiple portals all appended to document.body render in React's reconciler order, not necessarily in the order you'd expect visually. A dedicated #portal-root container gives you better control.

⚠️

Accessibility doesn't come for free. A portal-rendered modal needs role="dialog", aria-modal="true", focus management (trap focus inside while open), and a way to return focus to the trigger on close. The browser doesn't know your portal is a dialog — you have to tell it.

The Mental Model

createPortal is a DOM escape hatch, not a React tree escape hatch. The React tree stays intact — context, events, and reconciliation all work as normal. Only the DOM placement changes. That distinction is why portals work so well for UI that needs to visually break out of a container while logically belonging to the component that spawned it.

When you hit an overflow: hidden or stacking context wall, your first instinct shouldn't be to wrestle with CSS specificity or z-index arms races. Reach for createPortal, target document.body or a dedicated root element, and the problem disappears.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read