React Portals render components outside their parent DOM hierarchy. Here's how to use them for modals, tooltips, and overlays.
Basic Portal#
1import { createPortal } from 'react-dom';
2
3interface PortalProps {
4 children: React.ReactNode;
5 container?: Element | null;
6}
7
8function Portal({ children, container }: PortalProps) {
9 const [mounted, setMounted] = useState(false);
10
11 useEffect(() => {
12 setMounted(true);
13 return () => setMounted(false);
14 }, []);
15
16 if (!mounted) return null;
17
18 return createPortal(
19 children,
20 container || document.body
21 );
22}
23
24// Usage
25function App() {
26 return (
27 <div>
28 <h1>App Content</h1>
29 <Portal>
30 <div className="modal">This renders at document.body</div>
31 </Portal>
32 </div>
33 );
34}Modal Component#
1import { createPortal } from 'react-dom';
2import { useEffect, useRef, useCallback } from 'react';
3
4interface ModalProps {
5 isOpen: boolean;
6 onClose: () => void;
7 title?: string;
8 children: React.ReactNode;
9}
10
11function Modal({ isOpen, onClose, title, children }: ModalProps) {
12 const modalRef = useRef<HTMLDivElement>(null);
13 const previousFocusRef = useRef<HTMLElement | null>(null);
14
15 // Focus trap
16 useEffect(() => {
17 if (!isOpen) return;
18
19 // Save previous focus
20 previousFocusRef.current = document.activeElement as HTMLElement;
21
22 // Focus modal
23 modalRef.current?.focus();
24
25 // Restore focus on close
26 return () => {
27 previousFocusRef.current?.focus();
28 };
29 }, [isOpen]);
30
31 // Close on Escape
32 useEffect(() => {
33 if (!isOpen) return;
34
35 const handleKeyDown = (event: KeyboardEvent) => {
36 if (event.key === 'Escape') {
37 onClose();
38 }
39 };
40
41 document.addEventListener('keydown', handleKeyDown);
42 return () => document.removeEventListener('keydown', handleKeyDown);
43 }, [isOpen, onClose]);
44
45 // Prevent body scroll
46 useEffect(() => {
47 if (isOpen) {
48 document.body.style.overflow = 'hidden';
49 }
50 return () => {
51 document.body.style.overflow = '';
52 };
53 }, [isOpen]);
54
55 // Handle tab key for focus trap
56 const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
57 if (event.key !== 'Tab') return;
58
59 const modal = modalRef.current;
60 if (!modal) return;
61
62 const focusableElements = modal.querySelectorAll(
63 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
64 );
65
66 const firstElement = focusableElements[0] as HTMLElement;
67 const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
68
69 if (event.shiftKey && document.activeElement === firstElement) {
70 event.preventDefault();
71 lastElement.focus();
72 } else if (!event.shiftKey && document.activeElement === lastElement) {
73 event.preventDefault();
74 firstElement.focus();
75 }
76 }, []);
77
78 if (!isOpen) return null;
79
80 return createPortal(
81 <div
82 className="modal-overlay"
83 onClick={onClose}
84 aria-hidden="true"
85 >
86 <div
87 ref={modalRef}
88 className="modal-content"
89 role="dialog"
90 aria-modal="true"
91 aria-labelledby={title ? 'modal-title' : undefined}
92 tabIndex={-1}
93 onClick={(e) => e.stopPropagation()}
94 onKeyDown={handleKeyDown}
95 >
96 {title && <h2 id="modal-title">{title}</h2>}
97 {children}
98 <button onClick={onClose} aria-label="Close modal">
99 ×
100 </button>
101 </div>
102 </div>,
103 document.body
104 );
105}
106
107// Usage
108function App() {
109 const [isOpen, setIsOpen] = useState(false);
110
111 return (
112 <div>
113 <button onClick={() => setIsOpen(true)}>Open Modal</button>
114
115 <Modal
116 isOpen={isOpen}
117 onClose={() => setIsOpen(false)}
118 title="Confirm Action"
119 >
120 <p>Are you sure you want to proceed?</p>
121 <button onClick={() => setIsOpen(false)}>Cancel</button>
122 <button onClick={handleConfirm}>Confirm</button>
123 </Modal>
124 </div>
125 );
126}Tooltip Component#
1import { createPortal } from 'react-dom';
2import { useState, useRef, useEffect } from 'react';
3
4interface TooltipProps {
5 content: React.ReactNode;
6 children: React.ReactElement;
7 placement?: 'top' | 'bottom' | 'left' | 'right';
8}
9
10function Tooltip({ content, children, placement = 'top' }: TooltipProps) {
11 const [isVisible, setIsVisible] = useState(false);
12 const [position, setPosition] = useState({ top: 0, left: 0 });
13 const triggerRef = useRef<HTMLElement>(null);
14 const tooltipRef = useRef<HTMLDivElement>(null);
15
16 useEffect(() => {
17 if (!isVisible || !triggerRef.current || !tooltipRef.current) return;
18
19 const triggerRect = triggerRef.current.getBoundingClientRect();
20 const tooltipRect = tooltipRef.current.getBoundingClientRect();
21
22 let top = 0;
23 let left = 0;
24
25 switch (placement) {
26 case 'top':
27 top = triggerRect.top - tooltipRect.height - 8;
28 left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
29 break;
30 case 'bottom':
31 top = triggerRect.bottom + 8;
32 left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
33 break;
34 case 'left':
35 top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
36 left = triggerRect.left - tooltipRect.width - 8;
37 break;
38 case 'right':
39 top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
40 left = triggerRect.right + 8;
41 break;
42 }
43
44 setPosition({ top: top + window.scrollY, left: left + window.scrollX });
45 }, [isVisible, placement]);
46
47 const trigger = React.cloneElement(children, {
48 ref: triggerRef,
49 onMouseEnter: () => setIsVisible(true),
50 onMouseLeave: () => setIsVisible(false),
51 onFocus: () => setIsVisible(true),
52 onBlur: () => setIsVisible(false),
53 });
54
55 return (
56 <>
57 {trigger}
58 {isVisible &&
59 createPortal(
60 <div
61 ref={tooltipRef}
62 className={`tooltip tooltip-${placement}`}
63 style={{
64 position: 'absolute',
65 top: position.top,
66 left: position.left,
67 }}
68 role="tooltip"
69 >
70 {content}
71 </div>,
72 document.body
73 )}
74 </>
75 );
76}
77
78// Usage
79<Tooltip content="This is helpful information" placement="top">
80 <button>Hover me</button>
81</Tooltip>Dropdown Menu#
1interface DropdownProps {
2 trigger: React.ReactElement;
3 children: React.ReactNode;
4 align?: 'left' | 'right';
5}
6
7function Dropdown({ trigger, children, align = 'left' }: DropdownProps) {
8 const [isOpen, setIsOpen] = useState(false);
9 const [position, setPosition] = useState({ top: 0, left: 0 });
10 const triggerRef = useRef<HTMLElement>(null);
11 const menuRef = useRef<HTMLDivElement>(null);
12
13 useEffect(() => {
14 if (!isOpen) return;
15
16 const handleClickOutside = (event: MouseEvent) => {
17 if (
18 menuRef.current &&
19 !menuRef.current.contains(event.target as Node) &&
20 triggerRef.current &&
21 !triggerRef.current.contains(event.target as Node)
22 ) {
23 setIsOpen(false);
24 }
25 };
26
27 document.addEventListener('mousedown', handleClickOutside);
28 return () => document.removeEventListener('mousedown', handleClickOutside);
29 }, [isOpen]);
30
31 useEffect(() => {
32 if (!isOpen || !triggerRef.current) return;
33
34 const rect = triggerRef.current.getBoundingClientRect();
35
36 setPosition({
37 top: rect.bottom + window.scrollY + 4,
38 left: align === 'left'
39 ? rect.left + window.scrollX
40 : rect.right + window.scrollX,
41 });
42 }, [isOpen, align]);
43
44 const triggerElement = React.cloneElement(trigger, {
45 ref: triggerRef,
46 onClick: () => setIsOpen(!isOpen),
47 'aria-haspopup': 'menu',
48 'aria-expanded': isOpen,
49 });
50
51 return (
52 <>
53 {triggerElement}
54 {isOpen &&
55 createPortal(
56 <div
57 ref={menuRef}
58 className="dropdown-menu"
59 role="menu"
60 style={{
61 position: 'absolute',
62 top: position.top,
63 left: position.left,
64 transform: align === 'right' ? 'translateX(-100%)' : undefined,
65 }}
66 >
67 {children}
68 </div>,
69 document.body
70 )}
71 </>
72 );
73}
74
75// Usage
76<Dropdown
77 trigger={<button>Options</button>}
78 align="left"
79>
80 <button role="menuitem" onClick={handleEdit}>Edit</button>
81 <button role="menuitem" onClick={handleDelete}>Delete</button>
82</Dropdown>Toast Notifications#
1// ToastContext.tsx
2interface Toast {
3 id: string;
4 message: string;
5 type: 'success' | 'error' | 'info';
6}
7
8const ToastContext = createContext<{
9 addToast: (message: string, type: Toast['type']) => void;
10 removeToast: (id: string) => void;
11} | null>(null);
12
13function ToastProvider({ children }: { children: React.ReactNode }) {
14 const [toasts, setToasts] = useState<Toast[]>([]);
15
16 const addToast = useCallback((message: string, type: Toast['type']) => {
17 const id = Date.now().toString();
18 setToasts((prev) => [...prev, { id, message, type }]);
19
20 // Auto remove after 5 seconds
21 setTimeout(() => {
22 setToasts((prev) => prev.filter((t) => t.id !== id));
23 }, 5000);
24 }, []);
25
26 const removeToast = useCallback((id: string) => {
27 setToasts((prev) => prev.filter((t) => t.id !== id));
28 }, []);
29
30 return (
31 <ToastContext.Provider value={{ addToast, removeToast }}>
32 {children}
33 {createPortal(
34 <div className="toast-container" aria-live="polite">
35 {toasts.map((toast) => (
36 <div
37 key={toast.id}
38 className={`toast toast-${toast.type}`}
39 role="alert"
40 >
41 {toast.message}
42 <button onClick={() => removeToast(toast.id)}>×</button>
43 </div>
44 ))}
45 </div>,
46 document.body
47 )}
48 </ToastContext.Provider>
49 );
50}
51
52function useToast() {
53 const context = useContext(ToastContext);
54 if (!context) throw new Error('useToast must be used within ToastProvider');
55 return context;
56}
57
58// Usage
59function App() {
60 const { addToast } = useToast();
61
62 return (
63 <button onClick={() => addToast('Saved successfully!', 'success')}>
64 Save
65 </button>
66 );
67}Best Practices#
Accessibility:
✓ Manage focus properly
✓ Trap focus in modals
✓ Use proper ARIA attributes
✓ Support keyboard navigation
UX:
✓ Close on Escape key
✓ Close on click outside
✓ Prevent body scroll for modals
✓ Show/hide animations
Performance:
✓ Lazy render portal content
✓ Clean up event listeners
✓ Avoid unnecessary re-renders
✓ Use refs for DOM measurements
Conclusion#
React Portals enable rendering content outside the parent DOM tree while maintaining React's event bubbling and context. Use them for modals, tooltips, dropdowns, and notifications. Always consider accessibility with proper focus management and keyboard support.