| |

React 83 ⚛️ Portals with createPortal

⏱️ Estimated read: 13 min | |

A React component’s DOM output normally lands inside its parent’s DOM element. The component tree and the DOM tree are assumed to mirror each other. Portals break that assumption deliberately. createPortal(children, domNode) renders the children into a DOM node that exists anywhere in the document, while keeping the children in the same position in the React component tree . This solves a specific class of problems: a modal that must escape an overflow: hidden container, a tooltip that must render above every z-index in the page, or a React widget embedded inside server-rendered markup . The visual placement changes; the React behavior does not.

The distinction that matters is that a portal only changes the physical placement of the DOM node. In every other way, the JSX rendered into a portal acts as a child of the component that renders it. Context from the parent tree is accessible, and events bubble up through the React tree rather than the DOM tree . This is why a click inside a modal rendered into document.body still triggers an onClick handler on the component that opened the modal, even though the modal is not a DOM descendant of that component . The pattern is powerful, but it requires discipline around accessibility and SSR that the portal itself does not provide.

Key point: createPortal(children, domNode) renders JSX into a DOM node outside the parent’s DOM hierarchy while preserving the React tree relationship. Events bubble through the React tree, context is inherited, and the portal is conditionally rendered (not hidden with CSS). The portal does not manage focus, scroll locking, or ARIA relationships—those remain the component’s responsibility.


Why portals exist

The clipping problem. A modal rendered inside a component that has overflow: hidden, a transform, or a constrained z-index will be clipped or trapped in the wrong stacking context . The portal “teleports” the DOM to a different location—typically document.body or a dedicated #modal-root—where those constraints do not apply. The visual result is a modal that floats above the rest of the page regardless of where the component sits in the JSX.

The third-party integration problem. A React root that is only part of a server-rendered page (Rails, Django, a static site) needs to render interactive content into specific DOM regions without creating a second React root. The portal lets the application remain a single React tree with shared state, even though its parts render to different DOM nodes . The same mechanism is used to render React content inside a non-React map widget’s popup, where the widget provides a DOM node and React renders into it .

The event bubbling problem. The React tree is the source of truth for events, not the DOM tree. An event fired inside a portal will propagate to ancestors in the containing React tree, even if those elements are not ancestors in the DOM . This is deliberate: it allows a parent component to capture events from a modal without knowing whether the modal is implemented with a portal. The caveat is that a DOM-based assumption—”this click should not reach the parent”—will be wrong. The fix is to call stopPropagation inside the portal, or to move the portal higher in the React tree .

The conditional rendering problem. A portal should be rendered only when its content is actually needed. Pre-rendering dozens of portals and toggling CSS visibility adds DOM nodes and reconciliation work for content that is not visible. The recommended pattern is {isOpen && createPortal(...)}, not {createPortal(<Modal visible={isOpen} />, ...)} . The same rule applies to tooltips, dropdowns, and product tour steps.


a. The basic API and the modal pattern

The function signature is createPortal(children, domNode, key?). The children can be any renderable React content. The domNode must already exist; passing a different node during an update recreates the portal content . The optional key is the portal’s key.

// Import createPortal from react-dom
import { createPortal } from 'react-dom'
import { useState } from 'react'

function Modal({ isOpen, onClose, children }) {
  // Do not render the portal when the modal is closed
  if (!isOpen) return null

  // Render the children into document.body
  // The modal is visually outside the parent, but logically still a child
  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      {/* Stop the click from reaching the overlay */}
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  )
}

function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div className="app">
      {/* The button is inside the app, but the modal DOM is in body */}
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <p>This content is rendered in a portal.</p>
      </Modal>
    </div>
  )
}

The if (!isOpen) return null is the conditional rendering rule in practice. The portal is created only when the modal opens, and destroyed when it closes. The stopPropagation on the modal content prevents a click inside the modal from triggering the overlay’s onClick, which would close the modal .


b. Accessibility: the part the portal does not handle

A portal changes the DOM location; it does not make the modal accessible. The React documentation is explicit: follow the WAI-ARIA Modal Authoring Practices, manage keyboard focus, and ensure the user can move focus in and out of the portal naturally . The portal is a rendering mechanism, not an accessibility mechanism.

The required attributes on the dialog element are role="dialog", aria-modal="true", and aria-labelledby pointing to the title . The focus management is the harder part. On open, focus must move into the modal (to the first focusable element or a specific initial focus target). On close, focus must return to the element that triggered the modal. A focus trap prevents Tab from moving focus to the background content .

The broken ARIA relationship is a subtle portal-specific problem. A dropdown rendered into document.body is disconnected from its trigger in the accessibility tree. Screen readers may not announce that the menu appeared, or may not understand the relationship between the button and the menu. The fix is aria-owns on the trigger, pointing to the portaled menu’s ID, combined with aria-expanded and aria-haspopup on the trigger, and aria-labelledby on the menu pointing back to the trigger .

function AccessibleDropdown({ options }) {
  const [isOpen, setIsOpen] = useState(false)
  const buttonId = useId()
  const menuId = useId()

  return (
    <div>
      <button
        id={buttonId}
        onClick={() => setIsOpen(!isOpen)}
        aria-expanded={isOpen}
        aria-haspopup="menu"
        // Declare ownership of the portaled menu
        aria-owns={isOpen ? menuId : undefined}
      >
        Open
      </button>
      {isOpen && createPortal(
        <div id={menuId} role="menu" aria-labelledby={buttonId}>
          {options.map((o) => <div key={o.id} role="menuitem">{o.label}</div>)}
        </div>,
        document.body
      )}
    </div>
  )
}

The useId is React’s hook for stable IDs across server and client. Using Math.random() for IDs causes hydration mismatches because the server and client generate different values .


c. SSR and the client-only guard

createPortal requires browser DOM APIs. During server-side rendering, document does not exist. A component that calls createPortal unconditionally will crash on the server. In Next.js App Router, the component must be a Client Component ("use client"), and it must defer portal creation until after hydration .

'use client'

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

function ClientOnlyPortal({ children }) {
  // Start as false so the server and first client render agree
  const [mounted, setMounted] = useState(false)

  // After hydration, set mounted to true
  useEffect(() => {
    setMounted(true)
  }, [])

  // Render nothing until the browser is ready
  if (!mounted) return null

  // Now createPortal can safely access document.body
  return createPortal(children, document.body)
}

The mounted guard is the standard SSR workaround. It prevents the hydration mismatch that would occur if the server rendered nothing and the client immediately rendered a portal .

The portal cleanup is the other lifecycle concern. A portal container created with document.createElement in an effect must be removed in the cleanup function. Orphaned containers accumulate across re-renders and cause memory leaks . In React 18 Strict Mode, effects run twice in development, so the cleanup must handle the double-invocation correctly. Libraries like Fluent UI have adjusted their portal implementation to create the DOM node once and avoid removing it while it still has children, precisely because of Strict Mode and Concurrent Mode .


Complete Example Session

The following session builds an accessible modal with a portal, focus management, and SSR safety.

// ============================================
// PART 1: THE PORTAL WRAPPER
// ============================================
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'

function ClientPortal({ children }) {
  // Guard for SSR: render nothing until the client is mounted
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])

  // The portal target: document.body exists only in the browser
  if (!mounted) return null
  return createPortal(children, document.body)
}

// ============================================
// PART 2: THE MODAL WITH FOCUS MANAGEMENT
// ============================================
function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef(null)
  // Remember what was focused before the modal opened
  const previousFocus = useRef(null)

  useEffect(() => {
    if (!isOpen) return

    // Store the trigger so focus can return on close
    previousFocus.current = document.activeElement

    // Move focus into the modal
    modalRef.current?.focus()

    // Lock body scroll
    document.body.style.overflow = 'hidden'

    // Close on Escape
    const onKeyDown = (e) => {
      if (e.key === 'Escape') onClose()
    }
    document.addEventListener('keydown', onKeyDown)

    // Cleanup: restore scroll and focus
    return () => {
      document.body.style.overflow = ''
      document.removeEventListener('keydown', onKeyDown)
      previousFocus.current?.focus()
    }
  }, [isOpen, onClose])

  if (!isOpen) return null

  // The portal renders the modal into document.body
  // The overlay closes on click; the content stops propagation
  return (
    <ClientPortal>
      <div className="modal-overlay" onClick={onClose}>
        <div
          ref={modalRef}
          className="modal"
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          tabIndex={-1}
          onClick={(e) => e.stopPropagation()}
        >
          <h2 id="modal-title">{title}</h2>
          {children}
          <button onClick={onClose}>Close</button>
        </div>
      </div>
    </ClientPortal>
  )
}

// ============================================
// PART 3: THE PARENT
// ============================================
function App() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Confirm">
        <p>Are you sure?</p>
      </Modal>
    </div>
  )
}

The three parts cover the SSR-safe portal wrapper, the modal with focus trapping and scroll locking, and the parent that controls the open state. What the session demonstrates is that the portal is a rendering mechanism; the accessibility and lifecycle management are the component’s responsibility.


Quick Reference

API Signature

ParameterMeaning
childrenAny renderable React content
domNodeAn existing DOM node
keyOptional portal key

Event Behavior

BehaviorPortal
Event bubblingFollows React tree, not DOM tree
ContextInherited from React parent
StateShared with React parent

Accessibility Checklist

RequirementAttribute/Mechanism
Dialog rolerole="dialog"
Modal semanticsaria-modal="true"
Labelaria-labelledby
Focus into modalref.focus() on open
Focus returnStore activeElement, restore on close
Background inertinert or aria-hidden on siblings
Dropdown relationshiparia-owns on trigger

SSR Requirements

RequirementDetail
Client Component"use client" in Next.js App Router
Mount guarduseEffect + mounted state
ID generationuseId, not Math.random

Best Practices

✅ Do This:

// Conditionally render the portal
{isOpen && createPortal(<Modal />, document.body)}

// Stop propagation inside the portal when needed
<div onClick={(e) => e.stopPropagation()}>

// Guard for SSR
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])

❌ Don’t Do This:

// Pre-render portals and hide with CSS
{createPortal(<Modal visible={isOpen} />, document.body)} // wasteful

// Assume events follow the DOM tree
<div onClick={handleClick}><Portal>...</Portal></div> // bubbles in React tree

// Forget focus management
// The portal does not trap focus or return it

Common Pitfalls

PitfallWhy It HappensFix
Event fires unexpectedlyBubbling follows React treestopPropagation inside portal
Screen reader misses dropdownNo aria-ownsAdd aria-owns on trigger
SSR crashdocument undefinedmounted guard
Hydration mismatchRandom IDsuseId
Memory leakContainer not removedCleanup in useEffect

Real-World Examples

1. Basic portal

createPortal(<div>Modal</div>, document.body)

2. Modal overlay

createPortal(<div className="overlay" onClick={onClose}><div className="modal" onClick={e => e.stopPropagation()}>...</div></div>, document.body)

3. Conditional render

{isOpen && createPortal(<Modal />, document.body)}

4. SSR guard

const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null

5. Focus on open

useEffect(() => { modalRef.current?.focus() }, [])

6. Focus return on close

useEffect(() => { const prev = document.activeElement; return () => prev?.focus() }, [])

7. Body scroll lock

document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }

8. ARIA relationship

<button aria-owns={isOpen ? menuId : undefined} aria-expanded={isOpen} />

9. Event stop

<div onClick={(e) => e.stopPropagation()}>

10. Dedicated root

<div id="modal-root"></div>

Visual

Portal DOM Placement

┌──────────────────────────────────────────────┐
│  React Tree                                  │
│  <App>                                       │
│    <Modal>          ← portal component       │
│      <Dialog />                              │
│                                              │
│  DOM Tree                                    │
│  <body>                                      │
│    <div id="root">                           │
│      <button>Open</button>                   │
│    </div>                                    │
│    <div class="modal">  ← portal target      │
│      <Dialog />                              │
│    </div>                                    │
└──────────────────────────────────────────────┘

Event Bubbling

┌──────────────────────────────────────────────┐
│  Click inside portal                         │
│         │                                    │
│         ▼                                    │
│  Bubbles through React tree                  │
│  → parent component's onClick fires          │
│                                              │
│  Does NOT follow DOM tree                    │
│  → DOM parent does not receive it            │
└──────────────────────────────────────────────┘

SSR Guard

┌──────────────────────────────────────────────┐
│  Server render: mounted = false              │
│  → returns null                              │
│         │                                    │
│         ▼                                    │
│  Client hydration: mounted = false           │
│  → returns null (matches server)             │
│         │                                    │
│         ▼                                    │
│  useEffect: setMounted(true)                 │
│         │                                    │
│         ▼                                    │
│  Re-render: createPortal runs                │
└──────────────────────────────────────────────┘

Summary

ItemValue
FunctioncreatePortal(children, domNode, key?)
PurposeRender into a different DOM node
Event bubblingReact tree, not DOM tree
ContextInherited from React parent
Conditional render{isOpen && createPortal(...)}
SSR guardmounted state + useEffect
AccessibilityWAI-ARIA Modal Authoring Practices
ARIA relationshiparia-owns for portaled dropdowns

Key takeaways:

  • A portal changes DOM placement, not React behavior. The children remain in the React tree, inherit context, and bubble events through the React tree. The DOM parent is not the event parent .
  • Portals solve the clipping and z-index problem. A modal rendered inside an overflow: hidden container will be clipped; the portal renders it into document.body where the constraint does not apply .
  • Conditional rendering is the correct pattern. Render the portal only when it is needed, not always with CSS visibility toggling. Pre-rendering many portals adds DOM nodes and reconciliation work .
  • The portal does not provide accessibility. Focus management, scroll locking, ARIA roles, and the trigger-to-content relationship (aria-owns) are the component’s responsibility .
  • SSR requires a client-only guard. createPortal needs document, which does not exist on the server. Use a mounted state and a useEffect to defer portal creation until after hydration .
  • Event bubbling can be surprising. A click inside a portal bubbles to the React parent, not the DOM parent. Use stopPropagation inside the portal when the bubbling is unwanted .

Remember: createPortal is a rendering escape hatch, not a modal library. It moves the DOM node; everything else—focus, accessibility, scroll locking, event handling—remains the component’s job. Use it when a component must escape its container’s overflow or z-index, or when React must render into a non-React DOM node. Render it conditionally, guard it for SSR, and treat the accessibility requirements as mandatory rather than optional. The portal is the mechanism; the modal is the component you build around it.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!