ReactPortals

Portals

Normally, React renders a component into its parent's DOM node. A portal lets you render a component's output into a completely different DOM node — one that lives outside the component's DOM ancestor hierarchy. The component still behaves as if it is inside the React tree (events bubble, context works), but its DOM lives elsewhere.

JSX
import { createPortal } from 'react-dom'

function Modal({ children }) {
  // Renders children into document.body, not into the component's parent DOM node
  return createPortal(
    children,
    document.body
  )
}
Why Portals Exist

The classic problem: you build a modal or tooltip inside a deeply nested component. The parent somewhere up the tree has overflow: hidden or a z-index stacking context that clips or obscures your overlay. Even if you set z-index: 9999, it cannot escape the stacking context created by a parent with transform, filter, or will-change CSS properties.

Portals solve this by rendering the overlay directly into document.body or another top-level container, bypassing the CSS constraints of any ancestor element.

Building a Modal with a Portal

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

function Modal({ isOpen, onClose, title, children }) {
  const overlayRef = useRef(null)

  // Lock body scroll while modal is open
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden'
    }
    return () => {
      document.body.style.overflow = ''
    }
  }, [isOpen])

  // Close when clicking the backdrop
  function handleOverlayClick(e) {
    if (e.target === overlayRef.current) onClose()
  }

  if (!isOpen) return null

  return createPortal(
    <div
      ref={overlayRef}
      onClick={handleOverlayClick}
      style={{
        position: 'fixed',
        inset: 0,                    // shorthand for top/right/bottom/left: 0
        background: 'rgba(0,0,0,0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: 1000,
      }}
    >
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        style={{
          background: 'white',
          borderRadius: 8,
          padding: '2rem',
          maxWidth: 600,
          width: '90%',
        }}
      >
        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
          <h2 id="modal-title">{title}</h2>
          <button onClick={onClose} aria-label="Close modal">×</button>
        </div>
        {children}
      </div>
    </div>,
    document.body   // ← portal target: renders outside the component hierarchy
  )
}

// Usage — the modal is logically inside <App> but DOM-wise lives on <body>
function App() {
  const [open, setOpen] = React.useState(false)

  return (
    <div style={{ overflow: 'hidden', height: 200 }}>
      {/* overflow: hidden here would clip a non-portal modal */}
      <button onClick={() => setOpen(true)}>Open Modal</button>

      <Modal isOpen={open} onClose={() => setOpen(false)} title="Hello">
        <p>This modal renders on document.body, escaping overflow: hidden.</p>
      </Modal>
    </div>
  )
}
Portals Stay Inside the React Tree

Despite the DOM relocation, a portal is still a child of the component that renders it from React's perspective. This means:

  • Context propagates — a portal inside a &lt;ThemeProvider&gt; still receives the theme, even though the DOM node is on &lt;body&gt;

  • Events bubble through the React tree — a click inside the portal bubbles up through the React component ancestors, not through the DOM ancestors

  • State and lifecycle — the portal mounts and unmounts with its parent component, not independently

JSX
// Event bubbling through the React tree — not the DOM tree
function Parent() {
  function handleClick() {
    // This fires when the button inside the portal is clicked!
    // Even though the button's DOM parent is document.body, not this <div>
    console.log('clicked inside portal')
  }

  return (
    // onClick on this div catches portal events via React's synthetic event system
    <div onClick={handleClick}>
      <Modal>
        <button>Click me</button>  {/* DOM: child of body; React: child of Parent */}
      </Modal>
    </div>
  )
}
Note
React's synthetic event system handles event delegation at the React root, not at individual DOM nodes. This is why events bubble through the React tree rather than the DOM tree for portals.
Creating a Dedicated Portal Container

Instead of targeting document.body directly, many apps create a dedicated <div id="portal-root"> in the HTML for overlays. This makes the DOM structure cleaner and simplifies CSS targeting:

HTML
<!-- public/index.html or app layout -->
<body>
  <div id="root"></div>
  <div id="portal-root"></div>  <!-- portals render here -->
</body>

JSX
// Use the dedicated container instead of document.body
createPortal(children, document.getElementById('portal-root'))

// Or create one lazily if it doesn't exist
function getPortalRoot() {
  let el = document.getElementById('portal-root')
  if (!el) {
    el = document.createElement('div')
    el.id = 'portal-root'
    document.body.appendChild(el)
  }
  return el
}
Common Portal Use Cases
  • Modals and dialogs — need to escape stacking contexts and overlay the entire page

  • Tooltips and popovers — must appear above all other content regardless of parent z-index

  • Dropdown menus — need to overflow parents with overflow: hidden (e.g., table cells)

  • Toast notifications — floating messages anchored to a screen corner

  • Full-screen overlays — loading spinners, lightboxes, image viewers

Accessibility Implications
Warning
Moving DOM nodes with portals can break focus management and screen reader expectations. Always: set `role="dialog"` and `aria-modal="true"` on modals, move focus into the modal when it opens (`autoFocus` or `ref.current.focus()`), trap focus inside the modal (use a library like focus-trap-react), and return focus to the trigger element when the modal closes.
Tip
Libraries like Radix UI and Headless UI handle all portal accessibility concerns for you. Consider using them before rolling your own portal-based modal, unless you have specific requirements they don't cover.