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.
Table of Contents
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.
Modal Portal
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.