Back to Blog
ReactPortalsDOMModals

React Portals Guide

Master React Portals for rendering children outside the parent DOM hierarchy.

B
Bootspring Team
Engineering
August 12, 2018
7 min read

React Portals render children into a DOM node outside the parent component's hierarchy while maintaining React's event bubbling.

Basic Portal#

1import { createPortal } from 'react-dom'; 2 3function Modal({ children }) { 4 return createPortal( 5 children, 6 document.getElementById('modal-root') 7 ); 8} 9 10// Usage 11function App() { 12 const [showModal, setShowModal] = useState(false); 13 14 return ( 15 <div> 16 <button onClick={() => setShowModal(true)}> 17 Open Modal 18 </button> 19 20 {showModal && ( 21 <Modal> 22 <div className="modal"> 23 <h2>Modal Content</h2> 24 <button onClick={() => setShowModal(false)}> 25 Close 26 </button> 27 </div> 28 </Modal> 29 )} 30 </div> 31 ); 32} 33 34// HTML structure needed: 35// <body> 36// <div id="root"></div> 37// <div id="modal-root"></div> 38// </body>

Creating Portal Container#

1import { createPortal } from 'react-dom'; 2import { useEffect, useRef, useState } from 'react'; 3 4function Portal({ children }) { 5 const [mounted, setMounted] = useState(false); 6 const containerRef = useRef(null); 7 8 useEffect(() => { 9 // Create container on mount 10 containerRef.current = document.createElement('div'); 11 document.body.appendChild(containerRef.current); 12 setMounted(true); 13 14 // Cleanup on unmount 15 return () => { 16 document.body.removeChild(containerRef.current); 17 }; 18 }, []); 19 20 if (!mounted) return null; 21 22 return createPortal(children, containerRef.current); 23} 24 25// Usage - no need for pre-existing DOM element 26function App() { 27 return ( 28 <div> 29 <Portal> 30 <div>This renders at end of body</div> 31 </Portal> 32 </div> 33 ); 34}
1import { createPortal } from 'react-dom'; 2import { useEffect, useRef } from 'react'; 3 4function Modal({ isOpen, onClose, children }) { 5 const elRef = useRef(null); 6 7 if (!elRef.current) { 8 elRef.current = document.createElement('div'); 9 } 10 11 useEffect(() => { 12 const modalRoot = document.getElementById('modal-root'); 13 modalRoot.appendChild(elRef.current); 14 15 return () => { 16 modalRoot.removeChild(elRef.current); 17 }; 18 }, []); 19 20 useEffect(() => { 21 if (isOpen) { 22 document.body.style.overflow = 'hidden'; 23 } else { 24 document.body.style.overflow = ''; 25 } 26 27 return () => { 28 document.body.style.overflow = ''; 29 }; 30 }, [isOpen]); 31 32 if (!isOpen) return null; 33 34 return createPortal( 35 <div className="modal-overlay" onClick={onClose}> 36 <div 37 className="modal-content" 38 onClick={e => e.stopPropagation()} 39 > 40 <button 41 className="modal-close" 42 onClick={onClose} 43 > 44 × 45 </button> 46 {children} 47 </div> 48 </div>, 49 elRef.current 50 ); 51} 52 53// CSS 54const styles = ` 55.modal-overlay { 56 position: fixed; 57 inset: 0; 58 background: rgba(0, 0, 0, 0.5); 59 display: flex; 60 align-items: center; 61 justify-content: center; 62 z-index: 1000; 63} 64 65.modal-content { 66 background: white; 67 padding: 20px; 68 border-radius: 8px; 69 max-width: 500px; 70 width: 90%; 71 position: relative; 72} 73 74.modal-close { 75 position: absolute; 76 top: 10px; 77 right: 10px; 78 border: none; 79 background: none; 80 font-size: 24px; 81 cursor: pointer; 82} 83`;

Tooltip Portal#

1import { createPortal } from 'react-dom'; 2import { useState, useRef, useEffect } from 'react'; 3 4function Tooltip({ children, content }) { 5 const [show, setShow] = useState(false); 6 const [position, setPosition] = useState({ top: 0, left: 0 }); 7 const triggerRef = useRef(null); 8 9 const updatePosition = () => { 10 if (triggerRef.current) { 11 const rect = triggerRef.current.getBoundingClientRect(); 12 setPosition({ 13 top: rect.bottom + window.scrollY + 8, 14 left: rect.left + window.scrollX + rect.width / 2 15 }); 16 } 17 }; 18 19 useEffect(() => { 20 if (show) { 21 updatePosition(); 22 window.addEventListener('scroll', updatePosition); 23 window.addEventListener('resize', updatePosition); 24 } 25 26 return () => { 27 window.removeEventListener('scroll', updatePosition); 28 window.removeEventListener('resize', updatePosition); 29 }; 30 }, [show]); 31 32 return ( 33 <> 34 <span 35 ref={triggerRef} 36 onMouseEnter={() => setShow(true)} 37 onMouseLeave={() => setShow(false)} 38 > 39 {children} 40 </span> 41 42 {show && createPortal( 43 <div 44 style={{ 45 position: 'absolute', 46 top: position.top, 47 left: position.left, 48 transform: 'translateX(-50%)', 49 background: '#333', 50 color: 'white', 51 padding: '8px 12px', 52 borderRadius: '4px', 53 fontSize: '14px', 54 zIndex: 9999, 55 whiteSpace: 'nowrap' 56 }} 57 > 58 {content} 59 </div>, 60 document.body 61 )} 62 </> 63 ); 64} 65 66// Usage 67<Tooltip content="This is helpful information"> 68 <button>Hover me</button> 69</Tooltip>

Event Bubbling#

1// Events bubble through React tree, not DOM tree 2function Parent() { 3 const handleClick = (e) => { 4 console.log('Parent clicked!'); 5 // This fires even for portal children 6 }; 7 8 return ( 9 <div onClick={handleClick}> 10 <Portal> 11 <button>Click me</button> 12 {/* Click bubbles to Parent's onClick */} 13 </Portal> 14 </div> 15 ); 16} 17 18// Stop propagation if needed 19function Modal({ children, onClose }) { 20 return createPortal( 21 <div onClick={onClose}> 22 <div onClick={e => e.stopPropagation()}> 23 {children} 24 </div> 25 </div>, 26 document.getElementById('modal-root') 27 ); 28}
1import { createPortal } from 'react-dom'; 2import { useState, useRef, useEffect } from 'react'; 3 4function Dropdown({ trigger, children }) { 5 const [isOpen, setIsOpen] = useState(false); 6 const [position, setPosition] = useState({ top: 0, left: 0 }); 7 const triggerRef = useRef(null); 8 const dropdownRef = useRef(null); 9 10 useEffect(() => { 11 if (isOpen && triggerRef.current) { 12 const rect = triggerRef.current.getBoundingClientRect(); 13 setPosition({ 14 top: rect.bottom + window.scrollY, 15 left: rect.left + window.scrollX 16 }); 17 } 18 }, [isOpen]); 19 20 useEffect(() => { 21 const handleClickOutside = (e) => { 22 if ( 23 dropdownRef.current && 24 !dropdownRef.current.contains(e.target) && 25 !triggerRef.current.contains(e.target) 26 ) { 27 setIsOpen(false); 28 } 29 }; 30 31 const handleEscape = (e) => { 32 if (e.key === 'Escape') { 33 setIsOpen(false); 34 } 35 }; 36 37 if (isOpen) { 38 document.addEventListener('mousedown', handleClickOutside); 39 document.addEventListener('keydown', handleEscape); 40 } 41 42 return () => { 43 document.removeEventListener('mousedown', handleClickOutside); 44 document.removeEventListener('keydown', handleEscape); 45 }; 46 }, [isOpen]); 47 48 return ( 49 <> 50 <div ref={triggerRef} onClick={() => setIsOpen(!isOpen)}> 51 {trigger} 52 </div> 53 54 {isOpen && createPortal( 55 <div 56 ref={dropdownRef} 57 style={{ 58 position: 'absolute', 59 top: position.top, 60 left: position.left, 61 background: 'white', 62 border: '1px solid #ccc', 63 borderRadius: '4px', 64 boxShadow: '0 2px 10px rgba(0,0,0,0.1)', 65 zIndex: 1000 66 }} 67 > 68 {children} 69 </div>, 70 document.body 71 )} 72 </> 73 ); 74} 75 76// Usage 77<Dropdown trigger={<button>Menu</button>}> 78 <ul> 79 <li>Option 1</li> 80 <li>Option 2</li> 81 <li>Option 3</li> 82 </ul> 83</Dropdown>

Focus Management#

1import { createPortal } from 'react-dom'; 2import { useEffect, useRef } from 'react'; 3 4function FocusTrap({ children, isActive }) { 5 const containerRef = useRef(null); 6 const previousFocus = useRef(null); 7 8 useEffect(() => { 9 if (isActive) { 10 // Save current focus 11 previousFocus.current = document.activeElement; 12 13 // Focus first focusable element 14 const focusable = containerRef.current.querySelectorAll( 15 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 16 ); 17 if (focusable.length) { 18 focusable[0].focus(); 19 } 20 21 return () => { 22 // Restore focus on unmount 23 if (previousFocus.current) { 24 previousFocus.current.focus(); 25 } 26 }; 27 } 28 }, [isActive]); 29 30 const handleKeyDown = (e) => { 31 if (e.key !== 'Tab') return; 32 33 const focusable = containerRef.current.querySelectorAll( 34 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 35 ); 36 37 const first = focusable[0]; 38 const last = focusable[focusable.length - 1]; 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 56function AccessibleModal({ isOpen, onClose, children }) { 57 if (!isOpen) return null; 58 59 return createPortal( 60 <FocusTrap isActive={isOpen}> 61 <div 62 role="dialog" 63 aria-modal="true" 64 aria-labelledby="modal-title" 65 > 66 <h2 id="modal-title">Modal Title</h2> 67 {children} 68 <button onClick={onClose}>Close</button> 69 </div> 70 </FocusTrap>, 71 document.getElementById('modal-root') 72 ); 73}

Context in Portals#

1import { createContext, useContext } from 'react'; 2import { createPortal } from 'react-dom'; 3 4const ThemeContext = createContext('light'); 5 6function ThemedModal({ children }) { 7 // Context works through portals! 8 const theme = useContext(ThemeContext); 9 10 return createPortal( 11 <div className={`modal theme-${theme}`}> 12 {children} 13 </div>, 14 document.getElementById('modal-root') 15 ); 16} 17 18function App() { 19 return ( 20 <ThemeContext.Provider value="dark"> 21 <div> 22 <ThemedModal> 23 {/* This modal uses dark theme */} 24 <p>Portal content with context</p> 25 </ThemedModal> 26 </div> 27 </ThemeContext.Provider> 28 ); 29}

Multiple Portal Layers#

1function PortalManager() { 2 const [modals, setModals] = useState([]); 3 4 const openModal = (content) => { 5 const id = Date.now(); 6 setModals(prev => [...prev, { id, content }]); 7 return id; 8 }; 9 10 const closeModal = (id) => { 11 setModals(prev => prev.filter(m => m.id !== id)); 12 }; 13 14 return ( 15 <PortalContext.Provider value={{ openModal, closeModal }}> 16 {children} 17 {modals.map((modal, index) => ( 18 <Portal key={modal.id}> 19 <div style={{ zIndex: 1000 + index }}> 20 {modal.content} 21 </div> 22 </Portal> 23 ))} 24 </PortalContext.Provider> 25 ); 26}

Best Practices#

When to Use Portals: ✓ Modals and dialogs ✓ Tooltips and popovers ✓ Dropdown menus ✓ Notifications/toasts ✓ Full-screen overlays Accessibility: ✓ Use proper ARIA attributes ✓ Manage focus correctly ✓ Trap focus in modals ✓ Restore focus on close Implementation: ✓ Clean up portal containers ✓ Handle scroll and resize ✓ Use z-index properly ✓ Consider keyboard navigation Avoid: ✗ Overusing portals ✗ Breaking context unnecessarily ✗ Forgetting cleanup ✗ Ignoring accessibility

Conclusion#

React Portals enable rendering children outside the DOM hierarchy while preserving React's features like event bubbling and context. Use them for modals, tooltips, dropdowns, and any UI that needs to escape parent containers. Always manage focus properly, clean up portal containers, and maintain accessibility. Remember that events still bubble through the React component tree, not the DOM tree.

Share this article

Help spread the word about Bootspring