Back to Blog
ReactPortalsDOMComponents

React Portals Guide

Master React Portals for rendering components outside the DOM hierarchy while maintaining context.

B
Bootspring Team
Engineering
May 5, 2020
6 min read

React Portals render children into a DOM node outside the parent component's hierarchy. Here's how to use them.

Basic Portal#

1import { createPortal } from 'react-dom'; 2 3function Modal({ children, isOpen }) { 4 if (!isOpen) return null; 5 6 return createPortal( 7 <div className="modal-overlay"> 8 <div className="modal-content"> 9 {children} 10 </div> 11 </div>, 12 document.body 13 ); 14} 15 16// Usage 17function App() { 18 const [showModal, setShowModal] = useState(false); 19 20 return ( 21 <div> 22 <button onClick={() => setShowModal(true)}> 23 Open Modal 24 </button> 25 <Modal isOpen={showModal}> 26 <h2>Modal Title</h2> 27 <p>Modal content here</p> 28 <button onClick={() => setShowModal(false)}> 29 Close 30 </button> 31 </Modal> 32 </div> 33 ); 34}

Portal Container#

1// Create a dedicated portal root 2// In public/index.html: 3// <div id="portal-root"></div> 4 5function Portal({ children }) { 6 const [container] = useState(() => { 7 const el = document.createElement('div'); 8 el.className = 'portal-container'; 9 return el; 10 }); 11 12 useEffect(() => { 13 const portalRoot = document.getElementById('portal-root'); 14 portalRoot?.appendChild(container); 15 16 return () => { 17 portalRoot?.removeChild(container); 18 }; 19 }, [container]); 20 21 return createPortal(children, container); 22} 23 24// Usage 25function Tooltip({ children, content, position }) { 26 const [show, setShow] = useState(false); 27 28 return ( 29 <div 30 onMouseEnter={() => setShow(true)} 31 onMouseLeave={() => setShow(false)} 32 > 33 {children} 34 {show && ( 35 <Portal> 36 <div className="tooltip" style={position}> 37 {content} 38 </div> 39 </Portal> 40 )} 41 </div> 42 ); 43}
1import { createPortal } from 'react-dom'; 2import { useEffect, useCallback } from 'react'; 3 4function Modal({ isOpen, onClose, children, title }) { 5 // Handle escape key 6 const handleEscape = useCallback((e: KeyboardEvent) => { 7 if (e.key === 'Escape') { 8 onClose(); 9 } 10 }, [onClose]); 11 12 useEffect(() => { 13 if (isOpen) { 14 document.addEventListener('keydown', handleEscape); 15 document.body.style.overflow = 'hidden'; 16 } 17 18 return () => { 19 document.removeEventListener('keydown', handleEscape); 20 document.body.style.overflow = ''; 21 }; 22 }, [isOpen, handleEscape]); 23 24 if (!isOpen) return null; 25 26 return createPortal( 27 <div 28 className="modal-overlay" 29 onClick={onClose} 30 role="dialog" 31 aria-modal="true" 32 aria-labelledby="modal-title" 33 > 34 <div 35 className="modal-content" 36 onClick={(e) => e.stopPropagation()} 37 > 38 <header className="modal-header"> 39 <h2 id="modal-title">{title}</h2> 40 <button 41 onClick={onClose} 42 aria-label="Close modal" 43 > 44 × 45 </button> 46 </header> 47 <div className="modal-body"> 48 {children} 49 </div> 50 </div> 51 </div>, 52 document.body 53 ); 54} 55 56// Styles 57const styles = ` 58 .modal-overlay { 59 position: fixed; 60 inset: 0; 61 background: rgba(0, 0, 0, 0.5); 62 display: flex; 63 align-items: center; 64 justify-content: center; 65 z-index: 1000; 66 } 67 68 .modal-content { 69 background: white; 70 border-radius: 8px; 71 max-width: 500px; 72 width: 90%; 73 max-height: 90vh; 74 overflow: auto; 75 } 76`;
1function Dropdown({ trigger, items, onSelect }) { 2 const [isOpen, setIsOpen] = useState(false); 3 const [position, setPosition] = useState({ top: 0, left: 0 }); 4 const triggerRef = useRef<HTMLButtonElement>(null); 5 6 const updatePosition = useCallback(() => { 7 if (triggerRef.current) { 8 const rect = triggerRef.current.getBoundingClientRect(); 9 setPosition({ 10 top: rect.bottom + window.scrollY, 11 left: rect.left + window.scrollX, 12 }); 13 } 14 }, []); 15 16 useEffect(() => { 17 if (isOpen) { 18 updatePosition(); 19 window.addEventListener('scroll', updatePosition); 20 window.addEventListener('resize', updatePosition); 21 } 22 23 return () => { 24 window.removeEventListener('scroll', updatePosition); 25 window.removeEventListener('resize', updatePosition); 26 }; 27 }, [isOpen, updatePosition]); 28 29 // Close on outside click 30 useEffect(() => { 31 const handleClick = (e: MouseEvent) => { 32 if (!triggerRef.current?.contains(e.target as Node)) { 33 setIsOpen(false); 34 } 35 }; 36 37 if (isOpen) { 38 document.addEventListener('click', handleClick); 39 } 40 41 return () => { 42 document.removeEventListener('click', handleClick); 43 }; 44 }, [isOpen]); 45 46 return ( 47 <> 48 <button 49 ref={triggerRef} 50 onClick={() => setIsOpen(!isOpen)} 51 > 52 {trigger} 53 </button> 54 55 {isOpen && createPortal( 56 <ul 57 className="dropdown-menu" 58 style={{ 59 position: 'absolute', 60 top: position.top, 61 left: position.left, 62 }} 63 > 64 {items.map((item, i) => ( 65 <li key={i}> 66 <button onClick={() => { 67 onSelect(item); 68 setIsOpen(false); 69 }}> 70 {item.label} 71 </button> 72 </li> 73 ))} 74 </ul>, 75 document.body 76 )} 77 </> 78 ); 79}

Toast Notifications#

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

Focus Management#

1import { useRef, useEffect } from 'react'; 2 3function FocusTrap({ children, isActive }) { 4 const containerRef = useRef<HTMLDivElement>(null); 5 const previousFocus = useRef<HTMLElement | null>(null); 6 7 useEffect(() => { 8 if (isActive) { 9 // Store current focus 10 previousFocus.current = document.activeElement as HTMLElement; 11 12 // Focus first focusable element 13 const focusable = containerRef.current?.querySelectorAll( 14 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 15 ); 16 17 if (focusable?.length) { 18 (focusable[0] as HTMLElement).focus(); 19 } 20 21 return () => { 22 // Restore focus when unmounting 23 previousFocus.current?.focus(); 24 }; 25 } 26 }, [isActive]); 27 28 const handleKeyDown = (e: React.KeyboardEvent) => { 29 if (e.key !== 'Tab') return; 30 31 const focusable = containerRef.current?.querySelectorAll( 32 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 33 ); 34 35 if (!focusable?.length) return; 36 37 const first = focusable[0] as HTMLElement; 38 const last = focusable[focusable.length - 1] as HTMLElement; 39 40 if (e.shiftKey && document.activeElement === first) { 41 e.preventDefault(); 42 last.focus(); 43 } else if (!e.shiftKey && document.activeElement === last) { 44 e.preventDefault(); 45 first.focus(); 46 } 47 }; 48 49 return ( 50 <div ref={containerRef} onKeyDown={handleKeyDown}> 51 {children} 52 </div> 53 ); 54} 55 56// Usage with Modal 57function AccessibleModal({ isOpen, onClose, children }) { 58 if (!isOpen) return null; 59 60 return createPortal( 61 <FocusTrap isActive={isOpen}> 62 <div className="modal-overlay" onClick={onClose}> 63 <div 64 className="modal-content" 65 onClick={(e) => e.stopPropagation()} 66 role="dialog" 67 aria-modal="true" 68 > 69 {children} 70 </div> 71 </div> 72 </FocusTrap>, 73 document.body 74 ); 75}

Event Bubbling#

1// Events still bubble through React tree, not DOM tree 2function Parent() { 3 const handleClick = () => { 4 console.log('Parent clicked!'); 5 }; 6 7 return ( 8 <div onClick={handleClick}> 9 <Child /> 10 </div> 11 ); 12} 13 14function Child() { 15 return createPortal( 16 <button> 17 Click me (event bubbles to Parent!) 18 </button>, 19 document.body 20 ); 21} 22 23// To prevent bubbling 24function ChildWithStopPropagation() { 25 return createPortal( 26 <button onClick={(e) => e.stopPropagation()}> 27 Click me (event does NOT bubble) 28 </button>, 29 document.body 30 ); 31}

SSR Considerations#

1// Portal that works with SSR 2function SafePortal({ children }) { 3 const [mounted, setMounted] = useState(false); 4 5 useEffect(() => { 6 setMounted(true); 7 }, []); 8 9 if (!mounted) { 10 return null; 11 } 12 13 return createPortal(children, document.body); 14} 15 16// Or check for document 17function Portal({ children, container }) { 18 if (typeof document === 'undefined') { 19 return null; 20 } 21 22 return createPortal( 23 children, 24 container || document.body 25 ); 26}

Best Practices#

Use Portals For: ✓ Modals and dialogs ✓ Tooltips and popovers ✓ Dropdown menus ✓ Toast notifications Accessibility: ✓ Manage focus properly ✓ Use focus traps in modals ✓ Include ARIA attributes ✓ Handle escape key Event Handling: ✓ Remember events bubble through React tree ✓ Stop propagation when needed ✓ Clean up event listeners Avoid: ✗ Overusing portals for simple cases ✗ Forgetting cleanup in useEffect ✗ Ignoring SSR compatibility ✗ Breaking accessibility

Conclusion#

React Portals are essential for rendering UI that needs to visually "break out" of its container, like modals, tooltips, and dropdowns. Events still bubble through the React tree, and context works normally. Always manage focus properly and ensure accessibility when using portals for interactive content.

Share this article

Help spread the word about Bootspring