50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
reactMay 25, 2026·7 min read

React Portals: Rendering Outside the DOM Hierarchy (And When You Actually Need To)

Modals that break because of overflow:hidden, tooltips clipped by their parent — React portals fix all of this. Here's how they work and when to use them.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

React Portals: Rendering Outside the DOM Hierarchy (And When You Actually Need To)

We had a modal that worked perfectly in every Storybook story and fell apart completely in production. The close button was unclickable. The backdrop wasn't covering the full screen. Everything looked fine in devtools until we noticed the modal was rendering inside a div with `overflow: hidden` and `position: relative`. Classic. Three hours debugging a layout issue that React portals would have fixed in five minutes.

React portals let you render a component's output into a different DOM node than where the component lives in the React tree. The component is still part of your React tree — events bubble normally, context works, refs work — but the actual HTML ends up somewhere else in the document. Usually right inside `<body>`.

The Problem Portals Solve

CSS has a handful of properties that create what's called a "stacking context" or constrain positioning — `overflow: hidden`, `transform`, `filter`, `will-change`, `isolation: isolate`. When your modal or tooltip renders inside an element with any of these, it inherits those constraints. Your `position: fixed` dropdown that should cover the whole viewport? It's now `position: fixed` relative to the transformed ancestor, not the viewport. Your perfectly coded modal gets clipped by a sidebar's `overflow: hidden`.

The traditional fix was moving these UI elements to the end of `<body>` manually and syncing their state with JavaScript. Before React, you probably maintained a separate rendering root just for modals. It was messy and easy to break.

Portals give you the same result but you keep everything in React. The modal's state, context, event handlers — all still live in the component tree where they belong. Only the DOM output moves.

How to Create a Portal

The API is one function: `createPortal(children, container)`. You import it from `react-dom` and call it in your render/return.

import { createPortal } from 'react-dom';

function Modal({ isOpen, onClose, children }: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  if (!isOpen) return null;

  return createPortal(
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/50"
        onClick={onClose}
      />
      {/* Modal content */}
      <div className="relative z-10 bg-white rounded-lg p-6 max-w-md w-full">
        {children}
      </div>
    </div>,
    document.body
  );
}

The second argument is the DOM node you're rendering into. `document.body` is the most common choice. You can also create a dedicated container div if you want more control — useful if you need to apply specific styles or attributes to the portal root.

The component is still in your React tree. Context, Redux store, event bubbling — all of it works exactly as if the portal content was rendered in-place. Only the DOM position changes.

A Proper Portal Hook

Calling `document.body` directly works but it's not great for SSR (Next.js will complain during server rendering because `document` doesn't exist there). A custom hook handles this properly:

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

function usePortal(id = 'portal-root') {
  const [mounted, setMounted] = useState(false);
  const containerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    // Find existing container or create one
    let container = document.getElementById(id);
    if (!container) {
      container = document.createElement('div');
      container.id = id;
      document.body.appendChild(container);
    }
    containerRef.current = container;
    setMounted(true);

    return () => {
      // Only remove if we created it and it's empty
      if (container && container.childNodes.length === 0) {
        container.remove();
      }
    };
  }, [id]);

  return { mounted, container: containerRef.current };
}

// Usage
function Modal({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) {
  const { mounted, container } = usePortal('modal-root');

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

  return createPortal(children, container);
}

The `mounted` flag ensures we don't try to render into the DOM before the effect runs. On the server, `useEffect` never runs, so `mounted` stays false and nothing blows up. This is the pattern we use in every project — it's boring and reliable.

Event Bubbling Still Works (This Trips People Up)

This is the part that confuses developers the first time they use portals. Even though the modal HTML is a direct child of `<body>`, click events inside the modal still bubble up through the React component tree — not the DOM tree.

function Parent() {
  const handleClick = (e: React.MouseEvent) => {
    // This WILL fire when you click inside the portal
    // even though the portal is rendered in document.body
    // not inside this component in the DOM
    console.log('Click bubbled from portal:', e.target);
  };

  return (
    <div onClick={handleClick}>
      <p>I am the parent</p>
      <PortalChild />
    </div>
  );
}

function PortalChild() {
  return createPortal(
    <button>Click me</button>,
    document.body
  );
}
// Clicking the button triggers handleClick in Parent
// because React's synthetic event system follows the component tree

This is usually what you want — your modal can still close when a parent's state changes, and events propagate naturally. But it can surprise you if you're using `e.stopPropagation()` defensively and wondering why it's not working the way you expected. The bubbling follows React's virtual tree, not the real DOM.

Real Use Cases Worth Knowing

  • Modals and dialogs — the classic use case. Escape `overflow: hidden` on any ancestor.
  • Tooltips and popovers — especially inside tables, which are notoriously bad about containing positioned elements.
  • Dropdown menus in sidebars — sidebars often have `overflow: hidden` for their scroll behavior.
  • Toast notifications — render once at the app root level, trigger from anywhere.
  • Full-screen overlays — loading states, lightboxes, anything that needs to truly cover the viewport.
  • Third-party widget wrappers — if you need to render into a specific DOM node that a library controls.

Accessibility: Don't Skip This Part

Portals make the visual layout work but they don't automatically make your modal accessible. Screen readers follow DOM order, not React component order. If your modal renders at the end of `<body>` but focus stays in the middle of the page, keyboard users are going to have a bad time.

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

function AccessibleModal({ isOpen, onClose, title, children }: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  const modalRef = useRef<HTMLDivElement>(null);

  // Move focus into modal when it opens
  useEffect(() => {
    if (isOpen && modalRef.current) {
      modalRef.current.focus();
    }
  }, [isOpen]);

  // Close on Escape
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
    }
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      className="fixed inset-0 z-50 flex items-center justify-center"
    >
      <div
        className="absolute inset-0 bg-black/50"
        onClick={onClose}
        aria-hidden="true"
      />
      <div
        ref={modalRef}
        tabIndex={-1}
        className="relative z-10 bg-white rounded-lg p-6 max-w-md w-full focus:outline-none"
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose} aria-label="Close modal">
          ✕
        </button>
      </div>
    </div>,
    document.body
  );
}

The minimum you need: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` pointing to the modal title, focus moved into the modal on open, and Escape key to close. Focus trap (preventing Tab from leaving the modal) is the next level — for that, look at the `focus-trap-react` library or build it yourself if you're feeling brave.

When Not to Use Portals

Not every overlay needs a portal. If your component doesn't have any ancestor with `overflow: hidden`, `transform`, or similar CSS, a regular absolutely-positioned element works fine and is simpler. Portals add a tiny bit of complexity — the `document` dependency, the SSR considerations, the fact that devtools shows a somewhat confusing DOM tree.

Also worth knowing: if you're using React 18's new `<dialog>` element support, the native HTML `<dialog>` element with the `showModal()` method already renders above everything using the browser's top layer. No portal needed. Browser support is now good enough that this is a legitimate alternative for modals specifically.

Reach for portals when CSS stacking context is causing you actual problems. Don't add them preemptively to every modal 'just in case' — solve real problems, not hypothetical ones.

One more thing: if you're building with Next.js App Router, there's an extra wrinkle. Server Components can't use portals (they can't use `useEffect` or `createPortal` at all). Your portal components need to be Client Components — add `'use client'` at the top and you're fine. This is usually obvious because modals are inherently interactive, but it's worth remembering when you're trying to figure out why your portal isn't rendering.

We've baked accessible modal components using portals into the peal.dev templates precisely because this stuff is genuinely tedious to get right from scratch — the SSR safety, the focus management, the keyboard handling. It's the kind of thing you write once properly and reuse forever.

The practical takeaway: if you have a component that breaks visually because of its ancestors' CSS, portals are the right tool. Keep the component in your React tree where state and context make sense, let the DOM output live somewhere sensible. The separation between "where this component lives in React" and "where it renders in HTML" is exactly what makes portals powerful — once that clicks, you'll recognize about a dozen places in your current codebase where portals would have saved you an hour of debugging z-index and overflow issues.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders