Back to Blog
ReactPortalsModalsUI

React Portals for Modal and Overlay Components

Master React Portals for modals, tooltips, and dropdowns. From basic usage to accessibility to focus management.

B
Bootspring Team
Engineering
October 27, 2021
6 min read

React Portals render components outside their parent DOM hierarchy. Here's how to use them for modals, tooltips, and overlays.

Basic Portal#

1import { createPortal } from 'react-dom'; 2 3interface PortalProps { 4 children: React.ReactNode; 5 container?: Element | null; 6} 7 8function Portal({ children, container }: PortalProps) { 9 const [mounted, setMounted] = useState(false); 10 11 useEffect(() => { 12 setMounted(true); 13 return () => setMounted(false); 14 }, []); 15 16 if (!mounted) return null; 17 18 return createPortal( 19 children, 20 container || document.body 21 ); 22} 23 24// Usage 25function App() { 26 return ( 27 <div> 28 <h1>App Content</h1> 29 <Portal> 30 <div className="modal">This renders at document.body</div> 31 </Portal> 32 </div> 33 ); 34}
1import { createPortal } from 'react-dom'; 2import { useEffect, useRef, useCallback } 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 modalRef = useRef<HTMLDivElement>(null); 13 const previousFocusRef = useRef<HTMLElement | null>(null); 14 15 // Focus trap 16 useEffect(() => { 17 if (!isOpen) return; 18 19 // Save previous focus 20 previousFocusRef.current = document.activeElement as HTMLElement; 21 22 // Focus modal 23 modalRef.current?.focus(); 24 25 // Restore focus on close 26 return () => { 27 previousFocusRef.current?.focus(); 28 }; 29 }, [isOpen]); 30 31 // Close on Escape 32 useEffect(() => { 33 if (!isOpen) return; 34 35 const handleKeyDown = (event: KeyboardEvent) => { 36 if (event.key === 'Escape') { 37 onClose(); 38 } 39 }; 40 41 document.addEventListener('keydown', handleKeyDown); 42 return () => document.removeEventListener('keydown', handleKeyDown); 43 }, [isOpen, onClose]); 44 45 // Prevent body scroll 46 useEffect(() => { 47 if (isOpen) { 48 document.body.style.overflow = 'hidden'; 49 } 50 return () => { 51 document.body.style.overflow = ''; 52 }; 53 }, [isOpen]); 54 55 // Handle tab key for focus trap 56 const handleKeyDown = useCallback((event: React.KeyboardEvent) => { 57 if (event.key !== 'Tab') return; 58 59 const modal = modalRef.current; 60 if (!modal) return; 61 62 const focusableElements = modal.querySelectorAll( 63 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 64 ); 65 66 const firstElement = focusableElements[0] as HTMLElement; 67 const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; 68 69 if (event.shiftKey && document.activeElement === firstElement) { 70 event.preventDefault(); 71 lastElement.focus(); 72 } else if (!event.shiftKey && document.activeElement === lastElement) { 73 event.preventDefault(); 74 firstElement.focus(); 75 } 76 }, []); 77 78 if (!isOpen) return null; 79 80 return createPortal( 81 <div 82 className="modal-overlay" 83 onClick={onClose} 84 aria-hidden="true" 85 > 86 <div 87 ref={modalRef} 88 className="modal-content" 89 role="dialog" 90 aria-modal="true" 91 aria-labelledby={title ? 'modal-title' : undefined} 92 tabIndex={-1} 93 onClick={(e) => e.stopPropagation()} 94 onKeyDown={handleKeyDown} 95 > 96 {title && <h2 id="modal-title">{title}</h2>} 97 {children} 98 <button onClick={onClose} aria-label="Close modal"> 99 × 100 </button> 101 </div> 102 </div>, 103 document.body 104 ); 105} 106 107// Usage 108function App() { 109 const [isOpen, setIsOpen] = useState(false); 110 111 return ( 112 <div> 113 <button onClick={() => setIsOpen(true)}>Open Modal</button> 114 115 <Modal 116 isOpen={isOpen} 117 onClose={() => setIsOpen(false)} 118 title="Confirm Action" 119 > 120 <p>Are you sure you want to proceed?</p> 121 <button onClick={() => setIsOpen(false)}>Cancel</button> 122 <button onClick={handleConfirm}>Confirm</button> 123 </Modal> 124 </div> 125 ); 126}

Tooltip Component#

1import { createPortal } from 'react-dom'; 2import { useState, useRef, useEffect } from 'react'; 3 4interface TooltipProps { 5 content: React.ReactNode; 6 children: React.ReactElement; 7 placement?: 'top' | 'bottom' | 'left' | 'right'; 8} 9 10function Tooltip({ content, children, placement = 'top' }: TooltipProps) { 11 const [isVisible, setIsVisible] = useState(false); 12 const [position, setPosition] = useState({ top: 0, left: 0 }); 13 const triggerRef = useRef<HTMLElement>(null); 14 const tooltipRef = useRef<HTMLDivElement>(null); 15 16 useEffect(() => { 17 if (!isVisible || !triggerRef.current || !tooltipRef.current) return; 18 19 const triggerRect = triggerRef.current.getBoundingClientRect(); 20 const tooltipRect = tooltipRef.current.getBoundingClientRect(); 21 22 let top = 0; 23 let left = 0; 24 25 switch (placement) { 26 case 'top': 27 top = triggerRect.top - tooltipRect.height - 8; 28 left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; 29 break; 30 case 'bottom': 31 top = triggerRect.bottom + 8; 32 left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; 33 break; 34 case 'left': 35 top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; 36 left = triggerRect.left - tooltipRect.width - 8; 37 break; 38 case 'right': 39 top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; 40 left = triggerRect.right + 8; 41 break; 42 } 43 44 setPosition({ top: top + window.scrollY, left: left + window.scrollX }); 45 }, [isVisible, placement]); 46 47 const trigger = React.cloneElement(children, { 48 ref: triggerRef, 49 onMouseEnter: () => setIsVisible(true), 50 onMouseLeave: () => setIsVisible(false), 51 onFocus: () => setIsVisible(true), 52 onBlur: () => setIsVisible(false), 53 }); 54 55 return ( 56 <> 57 {trigger} 58 {isVisible && 59 createPortal( 60 <div 61 ref={tooltipRef} 62 className={`tooltip tooltip-${placement}`} 63 style={{ 64 position: 'absolute', 65 top: position.top, 66 left: position.left, 67 }} 68 role="tooltip" 69 > 70 {content} 71 </div>, 72 document.body 73 )} 74 </> 75 ); 76} 77 78// Usage 79<Tooltip content="This is helpful information" placement="top"> 80 <button>Hover me</button> 81</Tooltip>
1interface DropdownProps { 2 trigger: React.ReactElement; 3 children: React.ReactNode; 4 align?: 'left' | 'right'; 5} 6 7function Dropdown({ trigger, children, align = 'left' }: DropdownProps) { 8 const [isOpen, setIsOpen] = useState(false); 9 const [position, setPosition] = useState({ top: 0, left: 0 }); 10 const triggerRef = useRef<HTMLElement>(null); 11 const menuRef = useRef<HTMLDivElement>(null); 12 13 useEffect(() => { 14 if (!isOpen) return; 15 16 const handleClickOutside = (event: MouseEvent) => { 17 if ( 18 menuRef.current && 19 !menuRef.current.contains(event.target as Node) && 20 triggerRef.current && 21 !triggerRef.current.contains(event.target as Node) 22 ) { 23 setIsOpen(false); 24 } 25 }; 26 27 document.addEventListener('mousedown', handleClickOutside); 28 return () => document.removeEventListener('mousedown', handleClickOutside); 29 }, [isOpen]); 30 31 useEffect(() => { 32 if (!isOpen || !triggerRef.current) return; 33 34 const rect = triggerRef.current.getBoundingClientRect(); 35 36 setPosition({ 37 top: rect.bottom + window.scrollY + 4, 38 left: align === 'left' 39 ? rect.left + window.scrollX 40 : rect.right + window.scrollX, 41 }); 42 }, [isOpen, align]); 43 44 const triggerElement = React.cloneElement(trigger, { 45 ref: triggerRef, 46 onClick: () => setIsOpen(!isOpen), 47 'aria-haspopup': 'menu', 48 'aria-expanded': isOpen, 49 }); 50 51 return ( 52 <> 53 {triggerElement} 54 {isOpen && 55 createPortal( 56 <div 57 ref={menuRef} 58 className="dropdown-menu" 59 role="menu" 60 style={{ 61 position: 'absolute', 62 top: position.top, 63 left: position.left, 64 transform: align === 'right' ? 'translateX(-100%)' : undefined, 65 }} 66 > 67 {children} 68 </div>, 69 document.body 70 )} 71 </> 72 ); 73} 74 75// Usage 76<Dropdown 77 trigger={<button>Options</button>} 78 align="left" 79> 80 <button role="menuitem" onClick={handleEdit}>Edit</button> 81 <button role="menuitem" onClick={handleDelete}>Delete</button> 82</Dropdown>

Toast Notifications#

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

Best Practices#

Accessibility: ✓ Manage focus properly ✓ Trap focus in modals ✓ Use proper ARIA attributes ✓ Support keyboard navigation UX: ✓ Close on Escape key ✓ Close on click outside ✓ Prevent body scroll for modals ✓ Show/hide animations Performance: ✓ Lazy render portal content ✓ Clean up event listeners ✓ Avoid unnecessary re-renders ✓ Use refs for DOM measurements

Conclusion#

React Portals enable rendering content outside the parent DOM tree while maintaining React's event bubbling and context. Use them for modals, tooltips, dropdowns, and notifications. Always consider accessibility with proper focus management and keyboard support.

Share this article

Help spread the word about Bootspring