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}Modal Component#
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}Dropdown Portal#
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.