Back to Blog
ReactPortalsModalsDOM

React Portals Explained

Master React Portals for modals, tooltips, and overlays. Rendering outside the DOM hierarchy.

B
Bootspring Team
Engineering
October 8, 2020
7 min read

React Portals render children outside the parent DOM hierarchy while maintaining React's event system. Here's how to use them.

What Are Portals?#

1import { createPortal } from 'react-dom'; 2 3function Modal({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) { 4 if (!isOpen) return null; 5 6 // Render children in document.body instead of parent 7 return createPortal( 8 <div className="modal-overlay"> 9 <div className="modal-content"> 10 {children} 11 </div> 12 </div>, 13 document.body 14 ); 15} 16 17// Usage 18function App() { 19 const [isOpen, setIsOpen] = useState(false); 20 21 return ( 22 <div className="app"> 23 <button onClick={() => setIsOpen(true)}>Open Modal</button> 24 25 {/* Modal renders in body, not inside .app */} 26 <Modal isOpen={isOpen}> 27 <h2>Modal Title</h2> 28 <button onClick={() => setIsOpen(false)}>Close</button> 29 </Modal> 30 </div> 31 ); 32}

Why Use Portals?#

1// Problem: CSS overflow/z-index issues 2function ProblematicParent() { 3 return ( 4 <div style={{ overflow: 'hidden', position: 'relative' }}> 5 {/* Dropdown gets clipped by parent's overflow */} 6 <Dropdown> 7 <DropdownMenu /> {/* This gets cut off! */} 8 </Dropdown> 9 </div> 10 ); 11} 12 13// Solution: Portal renders outside parent 14function Dropdown({ children }: { children: React.ReactNode }) { 15 const [isOpen, setIsOpen] = useState(false); 16 const [position, setPosition] = useState({ top: 0, left: 0 }); 17 const buttonRef = useRef<HTMLButtonElement>(null); 18 19 useEffect(() => { 20 if (isOpen && buttonRef.current) { 21 const rect = buttonRef.current.getBoundingClientRect(); 22 setPosition({ 23 top: rect.bottom + window.scrollY, 24 left: rect.left + window.scrollX, 25 }); 26 } 27 }, [isOpen]); 28 29 return ( 30 <> 31 <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}> 32 Toggle 33 </button> 34 35 {isOpen && createPortal( 36 <div 37 className="dropdown-menu" 38 style={{ position: 'absolute', ...position }} 39 > 40 {children} 41 </div>, 42 document.body 43 )} 44 </> 45 ); 46}
1import { createPortal } from 'react-dom'; 2import { useEffect, useRef } from 'react'; 3 4interface ModalProps { 5 isOpen: boolean; 6 onClose: () => void; 7 title?: string; 8 children: React.ReactNode; 9} 10 11function Modal({ isOpen, onClose, title, children }: ModalProps) { 12 const overlayRef = useRef<HTMLDivElement>(null); 13 14 // Handle escape key 15 useEffect(() => { 16 const handleEscape = (e: KeyboardEvent) => { 17 if (e.key === 'Escape') onClose(); 18 }; 19 20 if (isOpen) { 21 document.addEventListener('keydown', handleEscape); 22 document.body.style.overflow = 'hidden'; 23 } 24 25 return () => { 26 document.removeEventListener('keydown', handleEscape); 27 document.body.style.overflow = ''; 28 }; 29 }, [isOpen, onClose]); 30 31 // Handle click outside 32 const handleOverlayClick = (e: React.MouseEvent) => { 33 if (e.target === overlayRef.current) { 34 onClose(); 35 } 36 }; 37 38 if (!isOpen) return null; 39 40 return createPortal( 41 <div 42 ref={overlayRef} 43 className="modal-overlay" 44 onClick={handleOverlayClick} 45 role="dialog" 46 aria-modal="true" 47 aria-labelledby="modal-title" 48 > 49 <div className="modal-content"> 50 <header className="modal-header"> 51 {title && <h2 id="modal-title">{title}</h2>} 52 <button 53 onClick={onClose} 54 aria-label="Close modal" 55 className="modal-close" 56 > 57 × 58 </button> 59 </header> 60 <div className="modal-body"> 61 {children} 62 </div> 63 </div> 64 </div>, 65 document.body 66 ); 67}

Tooltip Component#

1import { createPortal } from 'react-dom'; 2import { useState, useRef, useCallback } from 'react'; 3 4interface TooltipProps { 5 content: React.ReactNode; 6 children: React.ReactElement; 7 position?: 'top' | 'bottom' | 'left' | 'right'; 8} 9 10function Tooltip({ content, children, position = 'top' }: TooltipProps) { 11 const [isVisible, setIsVisible] = useState(false); 12 const [coords, setCoords] = useState({ top: 0, left: 0 }); 13 const triggerRef = useRef<HTMLElement>(null); 14 15 const calculatePosition = useCallback(() => { 16 if (!triggerRef.current) return; 17 18 const rect = triggerRef.current.getBoundingClientRect(); 19 const scrollX = window.scrollX; 20 const scrollY = window.scrollY; 21 22 const positions = { 23 top: { 24 top: rect.top + scrollY - 8, 25 left: rect.left + scrollX + rect.width / 2, 26 }, 27 bottom: { 28 top: rect.bottom + scrollY + 8, 29 left: rect.left + scrollX + rect.width / 2, 30 }, 31 left: { 32 top: rect.top + scrollY + rect.height / 2, 33 left: rect.left + scrollX - 8, 34 }, 35 right: { 36 top: rect.top + scrollY + rect.height / 2, 37 left: rect.right + scrollX + 8, 38 }, 39 }; 40 41 setCoords(positions[position]); 42 }, [position]); 43 44 const handleMouseEnter = () => { 45 calculatePosition(); 46 setIsVisible(true); 47 }; 48 49 const handleMouseLeave = () => { 50 setIsVisible(false); 51 }; 52 53 const trigger = React.cloneElement(children, { 54 ref: triggerRef, 55 onMouseEnter: handleMouseEnter, 56 onMouseLeave: handleMouseLeave, 57 onFocus: handleMouseEnter, 58 onBlur: handleMouseLeave, 59 }); 60 61 return ( 62 <> 63 {trigger} 64 {isVisible && createPortal( 65 <div 66 className={`tooltip tooltip-${position}`} 67 style={{ 68 position: 'absolute', 69 top: coords.top, 70 left: coords.left, 71 transform: position === 'top' || position === 'bottom' 72 ? 'translateX(-50%)' 73 : position === 'top' 74 ? 'translateY(-100%)' 75 : undefined, 76 }} 77 role="tooltip" 78 > 79 {content} 80 </div>, 81 document.body 82 )} 83 </> 84 ); 85}

Notification Toast System#

1import { createPortal } from 'react-dom'; 2import { createContext, useContext, useState, useCallback } from 'react'; 3 4interface Toast { 5 id: string; 6 message: string; 7 type: 'success' | 'error' | 'info'; 8} 9 10interface ToastContextType { 11 addToast: (message: string, type?: Toast['type']) => void; 12 removeToast: (id: string) => void; 13} 14 15const ToastContext = createContext<ToastContextType | null>(null); 16 17export function ToastProvider({ children }: { children: React.ReactNode }) { 18 const [toasts, setToasts] = useState<Toast[]>([]); 19 20 const addToast = useCallback((message: string, type: Toast['type'] = 'info') => { 21 const id = Date.now().toString(); 22 setToasts(prev => [...prev, { id, message, type }]); 23 24 // Auto remove after 5 seconds 25 setTimeout(() => { 26 setToasts(prev => prev.filter(t => t.id !== id)); 27 }, 5000); 28 }, []); 29 30 const removeToast = useCallback((id: string) => { 31 setToasts(prev => prev.filter(t => t.id !== id)); 32 }, []); 33 34 return ( 35 <ToastContext.Provider value={{ addToast, removeToast }}> 36 {children} 37 {createPortal( 38 <div className="toast-container" aria-live="polite"> 39 {toasts.map(toast => ( 40 <div 41 key={toast.id} 42 className={`toast toast-${toast.type}`} 43 role="alert" 44 > 45 {toast.message} 46 <button onClick={() => removeToast(toast.id)}>×</button> 47 </div> 48 ))} 49 </div>, 50 document.body 51 )} 52 </ToastContext.Provider> 53 ); 54} 55 56export function useToast() { 57 const context = useContext(ToastContext); 58 if (!context) { 59 throw new Error('useToast must be used within ToastProvider'); 60 } 61 return context; 62}

Focus Management#

1import { createPortal } from 'react-dom'; 2import { useEffect, useRef } from 'react'; 3 4function FocusTrapModal({ isOpen, onClose, children }: ModalProps) { 5 const modalRef = useRef<HTMLDivElement>(null); 6 const previousActiveElement = useRef<HTMLElement | null>(null); 7 8 useEffect(() => { 9 if (isOpen) { 10 // Store current focus 11 previousActiveElement.current = document.activeElement as HTMLElement; 12 13 // Focus the modal 14 modalRef.current?.focus(); 15 16 // Trap focus 17 const handleTab = (e: KeyboardEvent) => { 18 if (e.key !== 'Tab' || !modalRef.current) return; 19 20 const focusableElements = modalRef.current.querySelectorAll( 21 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 22 ); 23 const firstElement = focusableElements[0] as HTMLElement; 24 const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; 25 26 if (e.shiftKey && document.activeElement === firstElement) { 27 e.preventDefault(); 28 lastElement.focus(); 29 } else if (!e.shiftKey && document.activeElement === lastElement) { 30 e.preventDefault(); 31 firstElement.focus(); 32 } 33 }; 34 35 document.addEventListener('keydown', handleTab); 36 37 return () => { 38 document.removeEventListener('keydown', handleTab); 39 // Restore focus 40 previousActiveElement.current?.focus(); 41 }; 42 } 43 }, [isOpen]); 44 45 if (!isOpen) return null; 46 47 return createPortal( 48 <div className="modal-overlay"> 49 <div 50 ref={modalRef} 51 className="modal-content" 52 tabIndex={-1} 53 role="dialog" 54 aria-modal="true" 55 > 56 {children} 57 </div> 58 </div>, 59 document.body 60 ); 61}

Custom Portal Container#

1import { createPortal } from 'react-dom'; 2import { useEffect, useState } from 'react'; 3 4function usePortalContainer(id: string) { 5 const [container, setContainer] = useState<HTMLElement | null>(null); 6 7 useEffect(() => { 8 let element = document.getElementById(id); 9 10 if (!element) { 11 element = document.createElement('div'); 12 element.id = id; 13 document.body.appendChild(element); 14 } 15 16 setContainer(element); 17 18 return () => { 19 // Only remove if empty 20 if (element && element.childNodes.length === 0) { 21 element.remove(); 22 } 23 }; 24 }, [id]); 25 26 return container; 27} 28 29function LayeredPortal({ 30 children, 31 layer = 'modals', 32}: { 33 children: React.ReactNode; 34 layer?: 'modals' | 'tooltips' | 'notifications'; 35}) { 36 const container = usePortalContainer(`portal-${layer}`); 37 38 if (!container) return null; 39 40 return createPortal(children, container); 41} 42 43// CSS to manage layers 44/* 45#portal-modals { z-index: 1000; } 46#portal-tooltips { z-index: 2000; } 47#portal-notifications { z-index: 3000; } 48*/

Event Bubbling#

1// Events still bubble through React tree, not DOM tree 2function ParentComponent() { 3 const handleClick = (e: React.MouseEvent) => { 4 console.log('Parent clicked'); 5 // This fires even though Modal is in document.body 6 }; 7 8 return ( 9 <div onClick={handleClick}> 10 <Modal isOpen={true}> 11 <button>Click me</button> 12 {/* Click bubbles to ParentComponent through React tree */} 13 </Modal> 14 </div> 15 ); 16} 17 18// Stop propagation if needed 19function Modal({ children }: { children: React.ReactNode }) { 20 return createPortal( 21 <div onClick={(e) => e.stopPropagation()}> 22 {children} 23 </div>, 24 document.body 25 ); 26}

SSR Considerations#

1import { createPortal } from 'react-dom'; 2import { useEffect, useState } from 'react'; 3 4function ClientPortal({ children }: { children: React.ReactNode }) { 5 const [mounted, setMounted] = useState(false); 6 7 useEffect(() => { 8 setMounted(true); 9 }, []); 10 11 if (!mounted) return null; 12 13 return createPortal(children, document.body); 14} 15 16// Or check for document 17function SafePortal({ children }: { children: React.ReactNode }) { 18 if (typeof document === 'undefined') { 19 return null; 20 } 21 22 return createPortal(children, document.body); 23}

Best Practices#

Accessibility: ✓ Manage focus properly ✓ Use correct ARIA attributes ✓ Handle keyboard navigation ✓ Restore focus on close Performance: ✓ Don't overuse portals ✓ Clean up portal containers ✓ Lazy load modal content ✓ Use CSS animations Architecture: ✓ Create reusable portal hooks ✓ Manage z-index consistently ✓ Handle SSR properly ✓ Consider event bubbling UX: ✓ Block body scroll in modals ✓ Handle click outside ✓ Provide escape key handling ✓ Animate enter/exit

Conclusion#

React Portals solve DOM hierarchy issues for modals, tooltips, and overlays. They maintain React's event system while rendering outside the parent. Use them when CSS constraints prevent proper rendering, always manage focus and accessibility, and handle SSR appropriately.

Share this article

Help spread the word about Bootspring