React Portals render children into a DOM node outside the parent component's hierarchy. Here's how to use them.
Basic Portal#
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
13 );
14}
15
16// Usage
17function App() {
18 const [showModal, setShowModal] = useState(false);
19
20 return (
21 <div>
22 <button onClick={() => setShowModal(true)}>
23 Open Modal
24 </button>
25 <Modal isOpen={showModal}>
26 <h2>Modal Title</h2>
27 <p>Modal content here</p>
28 <button onClick={() => setShowModal(false)}>
29 Close
30 </button>
31 </Modal>
32 </div>
33 );
34}Portal Container#
1// Create a dedicated portal root
2// In public/index.html:
3// <div id="portal-root"></div>
4
5function Portal({ children }) {
6 const [container] = useState(() => {
7 const el = document.createElement('div');
8 el.className = 'portal-container';
9 return el;
10 });
11
12 useEffect(() => {
13 const portalRoot = document.getElementById('portal-root');
14 portalRoot?.appendChild(container);
15
16 return () => {
17 portalRoot?.removeChild(container);
18 };
19 }, [container]);
20
21 return createPortal(children, container);
22}
23
24// Usage
25function Tooltip({ children, content, position }) {
26 const [show, setShow] = useState(false);
27
28 return (
29 <div
30 onMouseEnter={() => setShow(true)}
31 onMouseLeave={() => setShow(false)}
32 >
33 {children}
34 {show && (
35 <Portal>
36 <div className="tooltip" style={position}>
37 {content}
38 </div>
39 </Portal>
40 )}
41 </div>
42 );
43}Modal Component#
1import { createPortal } from 'react-dom';
2import { useEffect, useCallback } from 'react';
3
4function Modal({ isOpen, onClose, children, title }) {
5 // Handle escape key
6 const handleEscape = useCallback((e: KeyboardEvent) => {
7 if (e.key === 'Escape') {
8 onClose();
9 }
10 }, [onClose]);
11
12 useEffect(() => {
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, handleEscape]);
23
24 if (!isOpen) return null;
25
26 return createPortal(
27 <div
28 className="modal-overlay"
29 onClick={onClose}
30 role="dialog"
31 aria-modal="true"
32 aria-labelledby="modal-title"
33 >
34 <div
35 className="modal-content"
36 onClick={(e) => e.stopPropagation()}
37 >
38 <header className="modal-header">
39 <h2 id="modal-title">{title}</h2>
40 <button
41 onClick={onClose}
42 aria-label="Close modal"
43 >
44 ×
45 </button>
46 </header>
47 <div className="modal-body">
48 {children}
49 </div>
50 </div>
51 </div>,
52 document.body
53 );
54}
55
56// Styles
57const styles = `
58 .modal-overlay {
59 position: fixed;
60 inset: 0;
61 background: rgba(0, 0, 0, 0.5);
62 display: flex;
63 align-items: center;
64 justify-content: center;
65 z-index: 1000;
66 }
67
68 .modal-content {
69 background: white;
70 border-radius: 8px;
71 max-width: 500px;
72 width: 90%;
73 max-height: 90vh;
74 overflow: auto;
75 }
76`;Dropdown with Portal#
1function Dropdown({ trigger, items, onSelect }) {
2 const [isOpen, setIsOpen] = useState(false);
3 const [position, setPosition] = useState({ top: 0, left: 0 });
4 const triggerRef = useRef<HTMLButtonElement>(null);
5
6 const updatePosition = useCallback(() => {
7 if (triggerRef.current) {
8 const rect = triggerRef.current.getBoundingClientRect();
9 setPosition({
10 top: rect.bottom + window.scrollY,
11 left: rect.left + window.scrollX,
12 });
13 }
14 }, []);
15
16 useEffect(() => {
17 if (isOpen) {
18 updatePosition();
19 window.addEventListener('scroll', updatePosition);
20 window.addEventListener('resize', updatePosition);
21 }
22
23 return () => {
24 window.removeEventListener('scroll', updatePosition);
25 window.removeEventListener('resize', updatePosition);
26 };
27 }, [isOpen, updatePosition]);
28
29 // Close on outside click
30 useEffect(() => {
31 const handleClick = (e: MouseEvent) => {
32 if (!triggerRef.current?.contains(e.target as Node)) {
33 setIsOpen(false);
34 }
35 };
36
37 if (isOpen) {
38 document.addEventListener('click', handleClick);
39 }
40
41 return () => {
42 document.removeEventListener('click', handleClick);
43 };
44 }, [isOpen]);
45
46 return (
47 <>
48 <button
49 ref={triggerRef}
50 onClick={() => setIsOpen(!isOpen)}
51 >
52 {trigger}
53 </button>
54
55 {isOpen && createPortal(
56 <ul
57 className="dropdown-menu"
58 style={{
59 position: 'absolute',
60 top: position.top,
61 left: position.left,
62 }}
63 >
64 {items.map((item, i) => (
65 <li key={i}>
66 <button onClick={() => {
67 onSelect(item);
68 setIsOpen(false);
69 }}>
70 {item.label}
71 </button>
72 </li>
73 ))}
74 </ul>,
75 document.body
76 )}
77 </>
78 );
79}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
10const ToastContext = createContext<{
11 addToast: (message: string, type: Toast['type']) => void;
12} | null>(null);
13
14function ToastProvider({ children }) {
15 const [toasts, setToasts] = useState<Toast[]>([]);
16
17 const addToast = useCallback((message: string, type: Toast['type']) => {
18 const id = Math.random().toString(36).slice(2);
19 setToasts(prev => [...prev, { id, message, type }]);
20
21 // Auto remove after 3 seconds
22 setTimeout(() => {
23 setToasts(prev => prev.filter(t => t.id !== id));
24 }, 3000);
25 }, []);
26
27 const removeToast = useCallback((id: string) => {
28 setToasts(prev => prev.filter(t => t.id !== id));
29 }, []);
30
31 return (
32 <ToastContext.Provider value={{ addToast }}>
33 {children}
34 {createPortal(
35 <div className="toast-container">
36 {toasts.map(toast => (
37 <div
38 key={toast.id}
39 className={`toast toast-${toast.type}`}
40 >
41 <span>{toast.message}</span>
42 <button onClick={() => removeToast(toast.id)}>
43 ×
44 </button>
45 </div>
46 ))}
47 </div>,
48 document.body
49 )}
50 </ToastContext.Provider>
51 );
52}
53
54function useToast() {
55 const context = useContext(ToastContext);
56 if (!context) {
57 throw new Error('useToast must be used within ToastProvider');
58 }
59 return context;
60}
61
62// Usage
63function App() {
64 return (
65 <ToastProvider>
66 <MyComponent />
67 </ToastProvider>
68 );
69}
70
71function MyComponent() {
72 const { addToast } = useToast();
73
74 return (
75 <button onClick={() => addToast('Saved!', 'success')}>
76 Save
77 </button>
78 );
79}Focus Management#
1import { useRef, useEffect } from 'react';
2
3function FocusTrap({ children, isActive }) {
4 const containerRef = useRef<HTMLDivElement>(null);
5 const previousFocus = useRef<HTMLElement | null>(null);
6
7 useEffect(() => {
8 if (isActive) {
9 // Store current focus
10 previousFocus.current = document.activeElement as HTMLElement;
11
12 // Focus first focusable element
13 const focusable = containerRef.current?.querySelectorAll(
14 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
15 );
16
17 if (focusable?.length) {
18 (focusable[0] as HTMLElement).focus();
19 }
20
21 return () => {
22 // Restore focus when unmounting
23 previousFocus.current?.focus();
24 };
25 }
26 }, [isActive]);
27
28 const handleKeyDown = (e: React.KeyboardEvent) => {
29 if (e.key !== 'Tab') return;
30
31 const focusable = containerRef.current?.querySelectorAll(
32 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
33 );
34
35 if (!focusable?.length) return;
36
37 const first = focusable[0] as HTMLElement;
38 const last = focusable[focusable.length - 1] as HTMLElement;
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
56// Usage with Modal
57function AccessibleModal({ isOpen, onClose, children }) {
58 if (!isOpen) return null;
59
60 return createPortal(
61 <FocusTrap isActive={isOpen}>
62 <div className="modal-overlay" onClick={onClose}>
63 <div
64 className="modal-content"
65 onClick={(e) => e.stopPropagation()}
66 role="dialog"
67 aria-modal="true"
68 >
69 {children}
70 </div>
71 </div>
72 </FocusTrap>,
73 document.body
74 );
75}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 <Child />
10 </div>
11 );
12}
13
14function Child() {
15 return createPortal(
16 <button>
17 Click me (event bubbles to Parent!)
18 </button>,
19 document.body
20 );
21}
22
23// To prevent bubbling
24function ChildWithStopPropagation() {
25 return createPortal(
26 <button onClick={(e) => e.stopPropagation()}>
27 Click me (event does NOT bubble)
28 </button>,
29 document.body
30 );
31}SSR Considerations#
1// Portal that works with SSR
2function SafePortal({ children }) {
3 const [mounted, setMounted] = useState(false);
4
5 useEffect(() => {
6 setMounted(true);
7 }, []);
8
9 if (!mounted) {
10 return null;
11 }
12
13 return createPortal(children, document.body);
14}
15
16// Or check for document
17function Portal({ children, container }) {
18 if (typeof document === 'undefined') {
19 return null;
20 }
21
22 return createPortal(
23 children,
24 container || document.body
25 );
26}Best Practices#
Use Portals For:
✓ Modals and dialogs
✓ Tooltips and popovers
✓ Dropdown menus
✓ Toast notifications
Accessibility:
✓ Manage focus properly
✓ Use focus traps in modals
✓ Include ARIA attributes
✓ Handle escape key
Event Handling:
✓ Remember events bubble through React tree
✓ Stop propagation when needed
✓ Clean up event listeners
Avoid:
✗ Overusing portals for simple cases
✗ Forgetting cleanup in useEffect
✗ Ignoring SSR compatibility
✗ Breaking accessibility
Conclusion#
React Portals are essential for rendering UI that needs to visually "break out" of its container, like modals, tooltips, and dropdowns. Events still bubble through the React tree, and context works normally. Always manage focus properly and ensure accessibility when using portals for interactive content.