Back to Blog
ReactPortalsDOMModals

React Portal Usage Guide

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

B
Bootspring Team
Engineering
July 14, 2019
7 min read

React Portals render children into a different DOM node while preserving React's component hierarchy. Here's how to use them.

Basic Portal#

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

Portal Container Hook#

1import { useEffect, useState } from 'react'; 2import { createPortal } from 'react-dom'; 3 4function usePortal(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 we created it and it's empty 20 if (element && element.childNodes.length === 0) { 21 element.remove(); 22 } 23 }; 24 }, [id]); 25 26 return container; 27} 28 29// Usage 30function Portal({ children, id = 'portal-root' }: { 31 children: React.ReactNode; 32 id?: string; 33}) { 34 const container = usePortal(id); 35 36 if (!container) return null; 37 38 return createPortal(children, container); 39}
1import { useEffect, useCallback } from 'react'; 2import { createPortal } from 'react-dom'; 3 4interface ModalProps { 5 isOpen: boolean; 6 onClose: () => void; 7 children: React.ReactNode; 8 closeOnOverlayClick?: boolean; 9} 10 11function Modal({ 12 isOpen, 13 onClose, 14 children, 15 closeOnOverlayClick = true, 16}: ModalProps) { 17 // Handle escape key 18 useEffect(() => { 19 const handleEscape = (e: KeyboardEvent) => { 20 if (e.key === 'Escape') { 21 onClose(); 22 } 23 }; 24 25 if (isOpen) { 26 document.addEventListener('keydown', handleEscape); 27 document.body.style.overflow = 'hidden'; 28 } 29 30 return () => { 31 document.removeEventListener('keydown', handleEscape); 32 document.body.style.overflow = ''; 33 }; 34 }, [isOpen, onClose]); 35 36 const handleOverlayClick = useCallback( 37 (e: React.MouseEvent) => { 38 if (closeOnOverlayClick && e.target === e.currentTarget) { 39 onClose(); 40 } 41 }, 42 [closeOnOverlayClick, onClose] 43 ); 44 45 if (!isOpen) return null; 46 47 return createPortal( 48 <div 49 className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" 50 onClick={handleOverlayClick} 51 role="dialog" 52 aria-modal="true" 53 > 54 <div className="bg-white rounded-lg p-6 max-w-md w-full mx-4"> 55 {children} 56 </div> 57 </div>, 58 document.body 59 ); 60}

Tooltip Portal#

1import { useState, useRef, useEffect } from 'react'; 2import { createPortal } from 'react-dom'; 3 4interface TooltipProps { 5 content: React.ReactNode; 6 children: React.ReactElement; 7} 8 9function Tooltip({ content, children }: TooltipProps) { 10 const [isVisible, setIsVisible] = useState(false); 11 const [position, setPosition] = useState({ top: 0, left: 0 }); 12 const triggerRef = useRef<HTMLElement>(null); 13 14 useEffect(() => { 15 if (isVisible && triggerRef.current) { 16 const rect = triggerRef.current.getBoundingClientRect(); 17 setPosition({ 18 top: rect.top - 8, 19 left: rect.left + rect.width / 2, 20 }); 21 } 22 }, [isVisible]); 23 24 const trigger = React.cloneElement(children, { 25 ref: triggerRef, 26 onMouseEnter: () => setIsVisible(true), 27 onMouseLeave: () => setIsVisible(false), 28 onFocus: () => setIsVisible(true), 29 onBlur: () => setIsVisible(false), 30 }); 31 32 return ( 33 <> 34 {trigger} 35 {isVisible && 36 createPortal( 37 <div 38 className="fixed z-50 px-2 py-1 bg-gray-900 text-white text-sm rounded transform -translate-x-1/2 -translate-y-full" 39 style={{ top: position.top, left: position.left }} 40 role="tooltip" 41 > 42 {content} 43 </div>, 44 document.body 45 )} 46 </> 47 ); 48}
1import { useState, useRef, useEffect } from 'react'; 2import { createPortal } from 'react-dom'; 3 4interface DropdownProps { 5 trigger: React.ReactNode; 6 children: React.ReactNode; 7} 8 9function Dropdown({ trigger, children }: DropdownProps) { 10 const [isOpen, setIsOpen] = useState(false); 11 const [position, setPosition] = useState({ top: 0, left: 0, width: 0 }); 12 const triggerRef = useRef<HTMLButtonElement>(null); 13 const dropdownRef = useRef<HTMLDivElement>(null); 14 15 useEffect(() => { 16 if (isOpen && triggerRef.current) { 17 const rect = triggerRef.current.getBoundingClientRect(); 18 setPosition({ 19 top: rect.bottom + 4, 20 left: rect.left, 21 width: rect.width, 22 }); 23 } 24 }, [isOpen]); 25 26 useEffect(() => { 27 const handleClickOutside = (e: MouseEvent) => { 28 if ( 29 dropdownRef.current && 30 !dropdownRef.current.contains(e.target as Node) && 31 !triggerRef.current?.contains(e.target as Node) 32 ) { 33 setIsOpen(false); 34 } 35 }; 36 37 if (isOpen) { 38 document.addEventListener('mousedown', handleClickOutside); 39 } 40 41 return () => { 42 document.removeEventListener('mousedown', handleClickOutside); 43 }; 44 }, [isOpen]); 45 46 return ( 47 <> 48 <button 49 ref={triggerRef} 50 onClick={() => setIsOpen(!isOpen)} 51 aria-expanded={isOpen} 52 aria-haspopup="true" 53 > 54 {trigger} 55 </button> 56 {isOpen && 57 createPortal( 58 <div 59 ref={dropdownRef} 60 className="fixed z-50 bg-white border rounded-lg shadow-lg" 61 style={{ 62 top: position.top, 63 left: position.left, 64 minWidth: position.width, 65 }} 66 > 67 {children} 68 </div>, 69 document.body 70 )} 71 </> 72 ); 73}

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 10interface ToastContextValue { 11 addToast: (message: string, type: Toast['type']) => void; 12 removeToast: (id: string) => void; 13} 14 15const ToastContext = createContext<ToastContextValue | null>(null); 16 17function ToastProvider({ children }: { children: React.ReactNode }) { 18 const [toasts, setToasts] = useState<Toast[]>([]); 19 20 const addToast = useCallback((message: string, type: Toast['type']) => { 21 const id = crypto.randomUUID(); 22 setToasts((prev) => [...prev, { id, message, type }]); 23 24 setTimeout(() => { 25 setToasts((prev) => prev.filter((t) => t.id !== id)); 26 }, 5000); 27 }, []); 28 29 const removeToast = useCallback((id: string) => { 30 setToasts((prev) => prev.filter((t) => t.id !== id)); 31 }, []); 32 33 return ( 34 <ToastContext.Provider value={{ addToast, removeToast }}> 35 {children} 36 {createPortal( 37 <div className="fixed bottom-4 right-4 space-y-2 z-50"> 38 {toasts.map((toast) => ( 39 <div 40 key={toast.id} 41 className={`p-4 rounded-lg shadow-lg ${ 42 toast.type === 'success' ? 'bg-green-500' : 43 toast.type === 'error' ? 'bg-red-500' : 'bg-blue-500' 44 } text-white`} 45 > 46 {toast.message} 47 <button onClick={() => removeToast(toast.id)}>×</button> 48 </div> 49 ))} 50 </div>, 51 document.body 52 )} 53 </ToastContext.Provider> 54 ); 55} 56 57function useToast() { 58 const context = useContext(ToastContext); 59 if (!context) throw new Error('useToast must be used within ToastProvider'); 60 return context; 61}

Focus Trap#

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

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 <h1>Parent</h1> 10 <PortalChild /> 11 </div> 12 ); 13} 14 15function PortalChild() { 16 return createPortal( 17 <button onClick={() => console.log('Button clicked')}> 18 Click me (in portal) 19 </button>, 20 document.body 21 ); 22} 23 24// Clicking button logs: 25// "Button clicked" 26// "Parent clicked" (event bubbles in React tree)

SSR Considerations#

1import { useEffect, useState } from 'react'; 2import { createPortal } from 'react-dom'; 3 4function ClientOnlyPortal({ 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 with a selector 17function SafePortal({ 18 children, 19 selector = '#portal-root', 20}: { 21 children: React.ReactNode; 22 selector?: string; 23}) { 24 const [container, setContainer] = useState<Element | null>(null); 25 26 useEffect(() => { 27 setContainer(document.querySelector(selector)); 28 }, [selector]); 29 30 if (!container) return null; 31 32 return createPortal(children, container); 33}

Best Practices#

Use Cases: ✓ Modals and dialogs ✓ Tooltips and popovers ✓ Dropdown menus ✓ Toast notifications Accessibility: ✓ Implement focus trapping ✓ Add ARIA attributes ✓ Handle keyboard navigation ✓ Manage focus on open/close Styling: ✓ Use fixed positioning ✓ Handle z-index properly ✓ Consider scroll locking ✓ Position relative to trigger Avoid: ✗ Overusing portals ✗ Breaking accessibility ✗ Memory leaks (cleanup) ✗ Ignoring SSR

Conclusion#

React Portals render content outside the DOM hierarchy while maintaining React context and event bubbling. Use them for modals, tooltips, dropdowns, and any UI that needs to break out of container overflow or z-index constraints. Always implement proper accessibility with focus management, keyboard handling, and ARIA attributes. Handle SSR by checking for document availability before rendering.

Share this article

Help spread the word about Bootspring