React Portals: Modals and Tooltips Outside the DOM Tree (2026)

React Portals render children into a DOM node that exists outside the parent component's DOM hierarchy. This is how you escape overflow: hidden containers, z-index stacking contexts and fixed-position constraints. Modals, tooltips, dropdowns and toasts all benefit from portals — they render at document body level while staying fully wired into React's component tree.

createPortal Basics

import { createPortal } from 'react-dom'

function Tooltip({ children, target }) {
  // Renders into document.body, not into the component's parent
  return createPortal(
    <div className="tooltip">{children}</div>,
    document.body   // Target DOM node
  )
}

// The portal content:
// - Renders in document.body in the DOM
// - Lives inside the React component tree (context, state, events all work)
// - Is a child of Tooltip in React's tree, NOT in the DOM tree
Key insight: Portals maintain React's component hierarchy. Context provided above a portal is still available inside it. Events fired inside a portal bubble up through the React component tree (not the DOM tree) — so a click inside a portal bubbles to the portal's React parent, not its DOM parent.
import { createPortal } from 'react-dom'
import { useEffect, useRef } from 'react'

interface ModalProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const previousFocus = useRef<HTMLElement | null>(null)
  const modalRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (isOpen) {
      // Save current focus position
      previousFocus.current = document.activeElement as HTMLElement
      // Prevent body scroll
      document.body.style.overflow = 'hidden'
      // Move focus into modal
      modalRef.current?.focus()
    } else {
      document.body.style.overflow = ''
      // Restore focus
      previousFocus.current?.focus()
    }
    return () => { document.body.style.overflow = '' }
  }, [isOpen])

  useEffect(() => {
    if (!isOpen) return
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') onClose()
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [isOpen, onClose])

  if (!isOpen) return null

  return createPortal(
    <>
      {/* Backdrop */}
      <div
        onClick={onClose}
        style={{
          position: 'fixed', inset: 0,
          background: 'rgba(0,0,0,0.6)',
          backdropFilter: 'blur(4px)',
          zIndex: 9998,
        }}
        aria-hidden="true"
      />
      {/* Modal panel */}
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        style={{
          position: 'fixed',
          top: '50%', left: '50%',
          transform: 'translate(-50%, -50%)',
          background: '#0d1424',
          border: '1px solid rgba(99,102,241,0.3)',
          borderRadius: 16,
          padding: 32,
          width: 'min(90vw, 560px)',
          maxHeight: '85vh',
          overflowY: 'auto',
          zIndex: 9999,
          outline: 'none',
        }}
      >
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
          <h2 id="modal-title" style={{ margin: 0, color: '#e2e8f0' }}>{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            style={{ background: 'none', border: 'none', color: '#64748b', fontSize: 20, cursor: 'pointer' }}
          >
            ✕
          </button>
        </div>
        {children}
      </div>
    </>,
    document.body
  )
}

// Usage — works even inside overflow:hidden containers
function ProductCard({ product }) {
  const [showDetails, setShowDetails] = useState(false)
  return (
    <div style={{ overflow: 'hidden' }}>   {/* Would clip a normal modal */}
      <h3>{product.name}</h3>
      <button onClick={() => setShowDetails(true)}>View Details</button>
      <Modal isOpen={showDetails} onClose={() => setShowDetails(false)} title={product.name}>
        <ProductDetails product={product} />
      </Modal>
    </div>
  )
}

Tooltip Portal

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

interface TooltipPosition {
  top: number
  left: number
}

function Tooltip({ content, children }: { content: string; children: React.ReactElement }) {
  const [visible, setVisible] = useState(false)
  const [pos, setPos] = useState<TooltipPosition>({ top: 0, left: 0 })
  const triggerRef = useRef<HTMLElement>(null)

  const showTooltip = useCallback(() => {
    if (!triggerRef.current) return
    const rect = triggerRef.current.getBoundingClientRect()
    setPos({
      top: rect.top - 8 + window.scrollY,   // Above the trigger
      left: rect.left + rect.width / 2,     // Centered horizontally
    })
    setVisible(true)
  }, [])

  const hideTooltip = useCallback(() => setVisible(false), [])

  return (
    <>
      {/* Clone child to attach ref and event handlers */}
      {React.cloneElement(children, {
        ref: triggerRef,
        onMouseEnter: showTooltip,
        onMouseLeave: hideTooltip,
        onFocus: showTooltip,
        onBlur: hideTooltip,
        'aria-describedby': visible ? 'tooltip' : undefined,
      })}
      {visible && createPortal(
        <div
          id="tooltip"
          role="tooltip"
          style={{
            position: 'absolute',
            top: pos.top,
            left: pos.left,
            transform: 'translate(-50%, -100%)',
            background: '#1e293b',
            color: '#e2e8f0',
            padding: '6px 12px',
            borderRadius: 6,
            fontSize: 13,
            pointerEvents: 'none',
            zIndex: 10000,
            whiteSpace: 'nowrap',
            border: '1px solid rgba(99,102,241,0.3)',
          }}
        >
          {content}
        </div>,
        document.body
      )}
    </>
  )
}

// Usage
<Tooltip content="Copy to clipboard">
  <button>Copy</button>
</Tooltip>

Toast System

// Centralized toast portal — one portal, many toasts
const ToastContext = createContext<(toast: Toast) => void>(() => {})

export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<Toast[]>([])

  const addToast = useCallback((toast: Toast) => {
    const id = crypto.randomUUID()
    setToasts(prev => [...prev, { ...toast, id }])
    setTimeout(() => {
      setToasts(prev => prev.filter(t => t.id !== id))
    }, toast.duration ?? 4000)
  }, [])

  return (
    <ToastContext.Provider value={addToast}>
      {children}
      {createPortal(
        <div
          aria-live="polite"
          style={{
            position: 'fixed', bottom: 24, right: 24,
            display: 'flex', flexDirection: 'column', gap: 8,
            zIndex: 10000,
          }}
        >
          {toasts.map(toast => (
            <ToastItem key={toast.id} toast={toast} onDismiss={() =>
              setToasts(prev => prev.filter(t => t.id !== toast.id))
            } />
          ))}
        </div>,
        document.body
      )}
    </ToastContext.Provider>
  )
}

export const useToast = () => useContext(ToastContext)

// Usage anywhere in the app
function SaveButton() {
  const toast = useToast()
  async function handleSave() {
    await saveData()
    toast({ type: 'success', message: 'Saved successfully!' })
  }
  return <button onClick={handleSave}>Save</button>
}

Event Bubbling Through Portals

// Events in portals bubble through the REACT tree, not the DOM tree
function Parent() {
  function handleClick() {
    console.log('Parent caught the click!')   // This fires even though
    // the portal renders in document.body, outside Parent in the DOM
  }

  return (
    <div onClick={handleClick}>
      <Child />
    </div>
  )
}

function Child() {
  return createPortal(
    <button>Click me (renders in body, but click bubbles to Parent!)</button>,
    document.body
  )
}

// Practical implication: stop propagation if needed
createPortal(
  <div onClick={e => e.stopPropagation()}>   {/* Prevent bubbling to React parent */}
    Modal content
  </div>,
  document.body
)

SSR Considerations

// document.body doesn't exist during SSR — guard with useEffect
function ClientPortal({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false)

  useEffect(() => { setMounted(true) }, [])

  if (!mounted) return null
  return createPortal(children, document.body)
}

// Usage in Next.js
function Modal({ isOpen, children }) {
  if (!isOpen) return null
  return (
    <ClientPortal>
      <div className="modal">{children}</div>
    </ClientPortal>
  )
}

When to Use Portals

  • Modals and dialogs — must render above all other content, outside any overflow: hidden
  • Tooltips and popovers — positioned relative to viewport, not parent
  • Toast notifications — fixed-position, unaffected by scroll containers
  • Dropdowns — need to escape table cells, cards or sidebars with clipping
  • Third-party widget integration — render React into an external DOM node

When not to use portals: for anything that doesn't need to escape the DOM hierarchy. Adding a portal where a simple absolutely-positioned element would work adds unnecessary complexity.