React Portals
Portals let you render children into a DOM node outside the parent component's DOM hierarchy.
Basic Portal
jsx
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 // Render to body, not parent
13 )
14}
15
16// Usage
17function App() {
18 const [showModal, setShowModal] = useState(false)
19
20 return (
21 <div className="app">
22 <button onClick={() => setShowModal(true)}>Open Modal</button>
23
24 <Modal isOpen={showModal}>
25 <h2>Modal Title</h2>
26 <p>Modal content here</p>
27 <button onClick={() => setShowModal(false)}>Close</button>
28 </Modal>
29 </div>
30 )
31}Why Use Portals?
CSS Overflow Issues
jsx
1// Without portal - modal gets clipped
2<div style={{ overflow: 'hidden' }}>
3 <Modal>Content</Modal> {/* Gets clipped! */}
4</div>
5
6// With portal - renders to body, no clipping
7<div style={{ overflow: 'hidden' }}>
8 <Modal>Content</Modal> {/* Renders outside, visible! */}
9</div>Z-Index Stacking
jsx
1// Portals escape z-index stacking contexts
2function Tooltip({ children, content }) {
3 const [show, setShow] = useState(false)
4 const [coords, setCoords] = useState({ x: 0, y: 0 })
5 const ref = useRef()
6
7 const handleMouseEnter = () => {
8 const rect = ref.current.getBoundingClientRect()
9 setCoords({ x: rect.x, y: rect.bottom })
10 setShow(true)
11 }
12
13 return (
14 <>
15 <span
16 ref={ref}
17 onMouseEnter={handleMouseEnter}
18 onMouseLeave={() => setShow(false)}
19 >
20 {children}
21 </span>
22
23 {show && createPortal(
24 <div
25 className="tooltip"
26 style={{ position: 'fixed', left: coords.x, top: coords.y }}
27 >
28 {content}
29 </div>,
30 document.body
31 )}
32 </>
33 )
34}Complete Modal Component
jsx
1import { createPortal } from 'react-dom'
2import { useEffect, useRef } from 'react'
3
4function Modal({ isOpen, onClose, title, children }) {
5 const overlayRef = useRef()
6
7 // Handle escape key
8 useEffect(() => {
9 const handleEscape = (e) => {
10 if (e.key === 'Escape') onClose()
11 }
12
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, onClose])
23
24 // Handle click outside
25 const handleOverlayClick = (e) => {
26 if (e.target === overlayRef.current) {
27 onClose()
28 }
29 }
30
31 if (!isOpen) return null
32
33 return createPortal(
34 <div
35 ref={overlayRef}
36 className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
37 onClick={handleOverlayClick}
38 >
39 <div
40 className="bg-white rounded-lg shadow-xl max-w-md w-full m-4"
41 role="dialog"
42 aria-modal="true"
43 >
44 <div className="flex items-center justify-between p-4 border-b">
45 <h2 className="text-lg font-semibold">{title}</h2>
46 <button
47 onClick={onClose}
48 className="text-gray-400 hover:text-gray-600"
49 aria-label="Close"
50 >
51 ✕
52 </button>
53 </div>
54 <div className="p-4">
55 {children}
56 </div>
57 </div>
58 </div>,
59 document.body
60 )
61}Dropdown Menu
jsx
1function Dropdown({ trigger, children }) {
2 const [isOpen, setIsOpen] = useState(false)
3 const [position, setPosition] = useState({ top: 0, left: 0 })
4 const triggerRef = useRef()
5
6 const handleOpen = () => {
7 const rect = triggerRef.current.getBoundingClientRect()
8 setPosition({
9 top: rect.bottom + window.scrollY,
10 left: rect.left + window.scrollX
11 })
12 setIsOpen(true)
13 }
14
15 // Close on outside click
16 useEffect(() => {
17 if (!isOpen) return
18
19 const handleClick = (e) => {
20 if (!triggerRef.current.contains(e.target)) {
21 setIsOpen(false)
22 }
23 }
24
25 document.addEventListener('click', handleClick)
26 return () => document.removeEventListener('click', handleClick)
27 }, [isOpen])
28
29 return (
30 <>
31 <div ref={triggerRef} onClick={handleOpen}>
32 {trigger}
33 </div>
34
35 {isOpen && createPortal(
36 <div
37 className="absolute bg-white shadow-lg rounded-md py-2 min-w-48 z-50"
38 style={{ top: position.top, left: position.left }}
39 >
40 {children}
41 </div>,
42 document.body
43 )}
44 </>
45 )
46}
47
48// Usage
49<Dropdown trigger={<button>Menu</button>}>
50 <a href="#" className="block px-4 py-2 hover:bg-gray-100">Profile</a>
51 <a href="#" className="block px-4 py-2 hover:bg-gray-100">Settings</a>
52 <a href="#" className="block px-4 py-2 hover:bg-gray-100">Logout</a>
53</Dropdown>Portal Container
Create a dedicated portal container:
html
1<!-- index.html -->
2<body>
3 <div id="root"></div>
4 <div id="portal-root"></div>
5</body>jsx
1function Modal({ children, isOpen }) {
2 if (!isOpen) return null
3
4 const portalRoot = document.getElementById('portal-root')
5
6 return createPortal(children, portalRoot)
7}Event Bubbling
Events still bubble through React tree, not DOM tree:
jsx
1function App() {
2 const handleClick = () => console.log('App clicked')
3
4 return (
5 <div onClick={handleClick}>
6 <Modal isOpen={true}>
7 <button>Click me</button> {/* Still triggers App's onClick! */}
8 </Modal>
9 </div>
10 )
11}Portals are essential for modals, tooltips, and overlays!
