React Portals are a powerful feature that allows rendering components outside the normal DOM hierarchy while preserving all React properties such as event bubbling and context. This is an ideal solution for modals, tooltips, overlays, and other UI elements requiring special positioning.
A Portal is a way to render children in a DOM node that exists outside the parent component's hierarchy. Despite the element being rendered in a different place in the DOM, it retains all React properties.
1import { createPortal } from 'react-dom';
2
3function Modal({ children, isOpen }) {
4 if (!isOpen) return null;
5
6 // Render modal in #modal-root element instead of the current hierarchy
7 return createPortal(
8 <div className="modal-overlay">
9 <div className="modal-content">
10 {children}
11 </div>
12 </div>,
13 document.getElementById('modal-root') // Target DOM node
14 );
15}
16
17// In HTML there must exist:
18// <div id="modal-root"></div>1// Problem with normal rendering
2function App() {
3 return (
4 <div style={{ position: 'relative', zIndex: 1, overflow: 'hidden' }}>
5 <Header />
6 <main>
7 {/* Modal might be clipped by overflow: hidden */}
8 <Modal>
9 <p>This modal might not be visible!</p>
10 </Modal>
11 </main>
12 </div>
13 );
14}
15
16// Solution with Portal
17function PortalModal({ children, isOpen }) {
18 if (!isOpen) return null;
19
20 // Render directly in body - outside CSS constraints
21 return createPortal(
22 <div className="modal-backdrop">
23 <div className="modal">
24 {children}
25 </div>
26 </div>,
27 document.body
28 );
29}1// PortalProvider.jsx
2import { createContext, useContext, useState, useCallback } from 'react';
3import { createPortal } from 'react-dom';
4
5const PortalContext = createContext();
6
7export function PortalProvider({ children }) {
8 const [portals, setPortals] = useState(new Map());
9
10 const addPortal = useCallback((id, content, target = document.body) => {
11 setPortals(prev => new Map(prev).set(id, { content, target }));
12 }, []);
13
14 const removePortal = useCallback((id) => {
15 setPortals(prev => {
16 const newMap = new Map(prev);
17 newMap.delete(id);
18 return newMap;
19 });
20 }, []);
21
22 return (
23 <PortalContext.Provider value={{ addPortal, removePortal }}>
24 {children}
25 {Array.from(portals.entries()).map(([id, { content, target }]) =>
26 createPortal(
27 <div key={id} data-portal-id={id}>
28 {content}
29 </div>,
30 target
31 )
32 )}
33 </PortalContext.Provider>
34 );
35}
36
37export const usePortal = () => {
38 const context = useContext(PortalContext);
39 if (!context) {
40 throw new Error('usePortal must be used within PortalProvider');
41 }
42 return context;
43};1// usePortal.js
2import { useEffect, useRef, useState } from 'react';
3
4export function usePortal(id = 'portal') {
5 const [isClient, setIsClient] = useState(false);
6 const portalRef = useRef(null);
7
8 useEffect(() => {
9 setIsClient(true);
10
11 // Check if element already exists
12 let portalElement = document.getElementById(id);
13
14 // If it doesn't exist, create it
15 if (!portalElement) {
16 portalElement = document.createElement('div');
17 portalElement.id = id;
18 portalElement.setAttribute('data-portal', 'true');
19 document.body.appendChild(portalElement);
20 }
21
22 portalRef.current = portalElement;
23
24 // Cleanup - remove element if it was created by this hook
25 return () => {
26 if (portalElement && portalElement.getAttribute('data-created-by-hook')) {
27 document.body.removeChild(portalElement);
28 }
29 };
30 }, [id]);
31
32 return {
33 Portal: ({ children }) => {
34 if (!isClient || !portalRef.current) return null;
35
36 return createPortal(children, portalRef.current);
37 },
38 portalElement: portalRef.current
39 };
40}
41
42// Usage
43function MyComponent() {
44 const { Portal } = usePortal('my-custom-portal');
45 const [showModal, setShowModal] = useState(false);
46
47 return (
48 <div>
49 <button onClick={() => setShowModal(true)}>
50 Show modal
51 </button>
52
53 {showModal && (
54 <Portal>
55 <div className="modal-overlay" onClick={() => setShowModal(false)}>
56 <div className="modal-content" onClick={e => e.stopPropagation()}>
57 <h2>Modal via Portal</h2>
58 <button onClick={() => setShowModal(false)}>Close</button>
59 </div>
60 </div>
61 </Portal>
62 )}
63 </div>
64 );
65}1// DynamicPortal.jsx
2function DynamicPortal({
3 children,
4 targetSelector,
5 targetElement,
6 fallbackToBody = true
7}) {
8 const [target, setTarget] = useState(null);
9
10 useEffect(() => {
11 let targetNode = null;
12
13 if (targetElement) {
14 targetNode = targetElement;
15 } else if (targetSelector) {
16 targetNode = document.querySelector(targetSelector);
17 } else if (fallbackToBody) {
18 targetNode = document.body;
19 }
20
21 setTarget(targetNode);
22 }, [targetSelector, targetElement, fallbackToBody]);
23
24 if (!target) return null;
25
26 return createPortal(children, target);
27}
28
29// Usage example
30function App() {
31 return (
32 <div>
33 <div id="sidebar-portals"></div>
34 <div id="header-portals"></div>
35
36 <main>
37 <DynamicPortal targetSelector="#sidebar-portals">
38 <div>Content in sidebar</div>
39 </DynamicPortal>
40
41 <DynamicPortal targetSelector="#header-portals">
42 <div>Content in header</div>
43 </DynamicPortal>
44 </main>
45 </div>
46 );
47}1// ModalSystem.jsx
2import { createContext, useContext, useState, useId } from 'react';
3import { createPortal } from 'react-dom';
4
5const ModalContext = createContext();
6
7export function ModalProvider({ children }) {
8 const [modals, setModals] = useState([]);
9
10 const openModal = (content, options = {}) => {
11 const id = Date.now().toString();
12 const modal = {
13 id,
14 content,
15 ...options
16 };
17
18 setModals(prev => [...prev, modal]);
19 return id;
20 };
21
22 const closeModal = (id) => {
23 setModals(prev => prev.filter(modal => modal.id !== id));
24 };
25
26 const closeAllModals = () => {
27 setModals([]);
28 };
29
30 return (
31 <ModalContext.Provider value={{ openModal, closeModal, closeAllModals }}>
32 {children}
33 {modals.length > 0 && createPortal(
34 <div className="modal-container">
35 {modals.map((modal, index) => (
36 <ModalOverlay
37 key={modal.id}
38 modal={modal}
39 zIndex={1000 + index}
40 onClose={() => closeModal(modal.id)}
41 />
42 ))}
43 </div>,
44 document.body
45 )}
46 </ModalContext.Provider>
47 );
48}
49
50function ModalOverlay({ modal, zIndex, onClose }) {
51 return (
52 <div
53 className="modal-overlay"
54 style={{ zIndex }}
55 onClick={modal.closeOnOverlayClick !== false ? onClose : undefined}
56 >
57 <div
58 className={`modal-content ${modal.className || ''}`}
59 onClick={e => e.stopPropagation()}
60 style={modal.style}
61 >
62 {modal.showCloseButton !== false && (
63 <button
64 className="modal-close"
65 onClick={onClose}
66 aria-label="Close modal"
67 >
68 ×
69 </button>
70 )}
71 {modal.content}
72 </div>
73 </div>
74 );
75}
76
77export const useModal = () => {
78 const context = useContext(ModalContext);
79 if (!context) {
80 throw new Error('useModal must be used within ModalProvider');
81 }
82 return context;
83};
84
85// Example components
86function ConfirmationModal({ message, onConfirm, onCancel }) {
87 return (
88 <div>
89 <h3>Confirmation</h3>
90 <p>{message}</p>
91 <div className="modal-actions">
92 <button onClick={onCancel}>Cancel</button>
93 <button onClick={onConfirm} className="primary">
94 Confirm
95 </button>
96 </div>
97 </div>
98 );
99}
100
101function useConfirmation() {
102 const { openModal, closeModal } = useModal();
103
104 return (message) => {
105 return new Promise((resolve) => {
106 const id = openModal(
107 <ConfirmationModal
108 message={message}
109 onConfirm={() => {
110 resolve(true);
111 closeModal(id);
112 }}
113 onCancel={() => {
114 resolve(false);
115 closeModal(id);
116 }}
117 />,
118 { closeOnOverlayClick: false }
119 );
120 });
121 };
122}1// TooltipSystem.jsx
2function TooltipProvider({ children }) {
3 const [tooltip, setTooltip] = useState(null);
4 const timeoutRef = useRef();
5
6 const showTooltip = (content, targetElement, options = {}) => {
7 clearTimeout(timeoutRef.current);
8
9 const rect = targetElement.getBoundingClientRect();
10 const position = calculatePosition(rect, options.position || 'top');
11
12 setTooltip({
13 content,
14 position,
15 ...options
16 });
17 };
18
19 const hideTooltip = (delay = 0) => {
20 if (delay > 0) {
21 timeoutRef.current = setTimeout(() => {
22 setTooltip(null);
23 }, delay);
24 } else {
25 setTooltip(null);
26 }
27 };
28
29 useEffect(() => {
30 return () => clearTimeout(timeoutRef.current);
31 }, []);
32
33 return (
34 <TooltipContext.Provider value={{ showTooltip, hideTooltip }}>
35 {children}
36 {tooltip && createPortal(
37 <div
38 className={`tooltip tooltip--${tooltip.position}`}
39 style={{
40 left: tooltip.position.x,
41 top: tooltip.position.y,
42 zIndex: 9999
43 }}
44 >
45 {tooltip.content}
46 </div>,
47 document.body
48 )}
49 </TooltipContext.Provider>
50 );
51}
52
53function calculatePosition(targetRect, position) {
54 const tooltipSize = { width: 200, height: 40 }; // Approximate
55
56 const positions = {
57 top: {
58 x: targetRect.left + targetRect.width / 2 - tooltipSize.width / 2,
59 y: targetRect.top - tooltipSize.height - 8
60 },
61 bottom: {
62 x: targetRect.left + targetRect.width / 2 - tooltipSize.width / 2,
63 y: targetRect.bottom + 8
64 },
65 left: {
66 x: targetRect.left - tooltipSize.width - 8,
67 y: targetRect.top + targetRect.height / 2 - tooltipSize.height / 2
68 },
69 right: {
70 x: targetRect.right + 8,
71 y: targetRect.top + targetRect.height / 2 - tooltipSize.height / 2
72 }
73 };
74
75 return positions[position] || positions.top;
76}
77
78// Hook for easy usage
79function useTooltip() {
80 const { showTooltip, hideTooltip } = useContext(TooltipContext);
81
82 return {
83 show: (content, targetRef, options) => {
84 if (targetRef.current) {
85 showTooltip(content, targetRef.current, options);
86 }
87 },
88 hide: hideTooltip
89 };
90}1// NotificationSystem.jsx
2function NotificationProvider({ children }) {
3 const [notifications, setNotifications] = useState([]);
4
5 const addNotification = (notification) => {
6 const id = Date.now().toString();
7 const newNotification = {
8 id,
9 type: 'info',
10 duration: 5000,
11 ...notification
12 };
13
14 setNotifications(prev => [...prev, newNotification]);
15
16 // Auto-remove after specified time
17 if (newNotification.duration > 0) {
18 setTimeout(() => {
19 removeNotification(id);
20 }, newNotification.duration);
21 }
22
23 return id;
24 };
25
26 const removeNotification = (id) => {
27 setNotifications(prev => prev.filter(n => n.id !== id));
28 };
29
30 return (
31 <NotificationContext.Provider value={{ addNotification, removeNotification }}>
32 {children}
33 {createPortal(
34 <div className="notifications-container">
35 {notifications.map(notification => (
36 <NotificationItem
37 key={notification.id}
38 notification={notification}
39 onRemove={() => removeNotification(notification.id)}
40 />
41 ))}
42 </div>,
43 document.body
44 )}
45 </NotificationContext.Provider>
46 );
47}
48
49function NotificationItem({ notification, onRemove }) {
50 const [isVisible, setIsVisible] = useState(false);
51 const [isLeaving, setIsLeaving] = useState(false);
52
53 useEffect(() => {
54 // Entry animation
55 setTimeout(() => setIsVisible(true), 10);
56 }, []);
57
58 const handleRemove = () => {
59 setIsLeaving(true);
60 setTimeout(onRemove, 300); // Exit animation duration
61 };
62
63 return (
64 <div
65 className={`notification notification--${notification.type} ${
66 isVisible && !isLeaving ? 'notification--visible' : ''
67 } ${isLeaving ? 'notification--leaving' : ''}`}
68 >
69 <div className="notification-content">
70 {notification.title && (
71 <h4 className="notification-title">{notification.title}</h4>
72 )}
73 <p className="notification-message">{notification.message}</p>
74 </div>
75
76 {notification.actions && (
77 <div className="notification-actions">
78 {notification.actions.map((action, index) => (
79 <button
80 key={index}
81 onClick={() => {
82 action.handler();
83 if (action.closeOnClick !== false) {
84 handleRemove();
85 }
86 }}
87 className={`notification-action ${action.className || ''}`}
88 >
89 {action.label}
90 </button>
91 ))}
92 </div>
93 )}
94
95 <button
96 className="notification-close"
97 onClick={handleRemove}
98 aria-label="Close notification"
99 >
100 ×
101 </button>
102 </div>
103 );
104}
105
106// Hook for notifications
107function useNotifications() {
108 const { addNotification } = useContext(NotificationContext);
109
110 return {
111 success: (message, options) => addNotification({
112 type: 'success',
113 message,
114 ...options
115 }),
116 error: (message, options) => addNotification({
117 type: 'error',
118 message,
119 duration: 0, // Don't auto-dismiss
120 ...options
121 }),
122 warning: (message, options) => addNotification({
123 type: 'warning',
124 message,
125 ...options
126 }),
127 info: (message, options) => addNotification({
128 type: 'info',
129 message,
130 ...options
131 })
132 };
133}1function PortalEventExample() {
2 const [count, setCount] = useState(0);
3
4 const handleParentClick = () => {
5 console.log('Parent clicked');
6 setCount(c => c + 1);
7 };
8
9 const handlePortalClick = (e) => {
10 console.log('Portal clicked');
11 // Event will "bubble" to parent even though Portal is outside DOM
12 // e.stopPropagation(); // Stops bubbling
13 };
14
15 return (
16 <div onClick={handleParentClick} className="parent">
17 <p>Clicks: {count}</p>
18 <p>Click the button in the portal - event will bubble to parent!</p>
19
20 {createPortal(
21 <div className="portal-content">
22 <button onClick={handlePortalClick}>
23 Button in Portal
24 </button>
25 </div>,
26 document.body
27 )}
28 </div>
29 );
30}1// Context works normally through Portals
2const ThemeContext = createContext();
3
4function App() {
5 return (
6 <ThemeContext.Provider value={{ theme: 'dark' }}>
7 <div className="app">
8 <MainContent />
9
10 {/* Portal has access to ThemeContext */}
11 {createPortal(
12 <PortalComponent />,
13 document.getElementById('portal-root')
14 )}
15 </div>
16 </ThemeContext.Provider>
17 );
18}
19
20function PortalComponent() {
21 const { theme } = useContext(ThemeContext); // Works!
22
23 return (
24 <div className={`portal-component theme-${theme}`}>
25 I have access to context: {theme}
26 </div>
27 );
28}1function CleanPortal({ children, targetId }) {
2 const [portalTarget, setPortalTarget] = useState(null);
3
4 useEffect(() => {
5 // Create target element if it doesn't exist
6 let target = document.getElementById(targetId);
7 let shouldCleanup = false;
8
9 if (!target) {
10 target = document.createElement('div');
11 target.id = targetId;
12 document.body.appendChild(target);
13 shouldCleanup = true;
14 }
15
16 setPortalTarget(target);
17
18 // Cleanup
19 return () => {
20 if (shouldCleanup && target.parentNode) {
21 target.parentNode.removeChild(target);
22 }
23 };
24 }, [targetId]);
25
26 if (!portalTarget) return null;
27
28 return createPortal(children, portalTarget);
29}1function AccessibleModal({ children, isOpen, onClose, title }) {
2 const modalRef = useRef();
3 const previousFocusRef = useRef();
4
5 useEffect(() => {
6 if (isOpen) {
7 // Save currently focused element
8 previousFocusRef.current = document.activeElement;
9
10 // Move focus to modal
11 if (modalRef.current) {
12 modalRef.current.focus();
13 }
14
15 // Lock scroll on body
16 document.body.style.overflow = 'hidden';
17
18 // Handle ESC key
19 const handleEscape = (e) => {
20 if (e.key === 'Escape') {
21 onClose();
22 }
23 };
24
25 document.addEventListener('keydown', handleEscape);
26
27 return () => {
28 document.removeEventListener('keydown', handleEscape);
29 document.body.style.overflow = '';
30
31 // Restore focus
32 if (previousFocusRef.current) {
33 previousFocusRef.current.focus();
34 }
35 };
36 }
37 }, [isOpen, onClose]);
38
39 if (!isOpen) return null;
40
41 return createPortal(
42 <div
43 className="modal-overlay"
44 onClick={onClose}
45 role="dialog"
46 aria-modal="true"
47 aria-labelledby="modal-title"
48 >
49 <div
50 ref={modalRef}
51 className="modal-content"
52 onClick={e => e.stopPropagation()}
53 tabIndex={-1}
54 >
55 <h2 id="modal-title">{title}</h2>
56 {children}
57
58 {/* Focus trap */}
59 <button
60 style={{ position: 'absolute', left: '-9999px' }}
61 onFocus={() => modalRef.current?.focus()}
62 />
63 </div>
64 </div>,
65 document.body
66 );
67}1// Lazy Portal - loads only when needed
2function LazyPortal({ children, isActive, targetId }) {
3 const [portalRoot, setPortalRoot] = useState(null);
4
5 useEffect(() => {
6 if (isActive && !portalRoot) {
7 const root = document.getElementById(targetId) || document.body;
8 setPortalRoot(root);
9 }
10 }, [isActive, targetId, portalRoot]);
11
12 // Render only when active and when we have a target
13 if (!isActive || !portalRoot) {
14 return null;
15 }
16
17 return createPortal(children, portalRoot);
18}
19
20// Memoized Portal for frequent re-renders
21const MemoizedPortal = memo(function MemoizedPortal({
22 children,
23 targetId,
24 shouldUpdate
25}) {
26 const portalTarget = useMemo(() => {
27 return document.getElementById(targetId) || document.body;
28 }, [targetId]);
29
30 return createPortal(children, portalTarget);
31}, (prevProps, nextProps) => {
32 // Custom comparison - re-render only when shouldUpdate changes
33 return prevProps.shouldUpdate === nextProps.shouldUpdate;
34});React Portals are an extremely useful tool for creating advanced user interfaces. They enable rendering components outside the normal DOM hierarchy while preserving all React properties. The key to success is proper lifecycle management, accessibility, and performance.