React Portals render children outside the parent DOM hierarchy while maintaining React's event system. Here's how to use them.
What Are Portals?#
1import { createPortal } from 'react-dom';
2
3function Modal({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) {
4 if (!isOpen) return null;
5
6 // Render children in document.body instead of parent
7 return createPortal(
8 <div className="modal-overlay">
9 <div className="modal-content">
10 {children}
11 </div>
12 </div>,
13 document.body
14 );
15}
16
17// Usage
18function App() {
19 const [isOpen, setIsOpen] = useState(false);
20
21 return (
22 <div className="app">
23 <button onClick={() => setIsOpen(true)}>Open Modal</button>
24
25 {/* Modal renders in body, not inside .app */}
26 <Modal isOpen={isOpen}>
27 <h2>Modal Title</h2>
28 <button onClick={() => setIsOpen(false)}>Close</button>
29 </Modal>
30 </div>
31 );
32}Why Use Portals?#
1// Problem: CSS overflow/z-index issues
2function ProblematicParent() {
3 return (
4 <div style={{ overflow: 'hidden', position: 'relative' }}>
5 {/* Dropdown gets clipped by parent's overflow */}
6 <Dropdown>
7 <DropdownMenu /> {/* This gets cut off! */}
8 </Dropdown>
9 </div>
10 );
11}
12
13// Solution: Portal renders outside parent
14function Dropdown({ children }: { children: React.ReactNode }) {
15 const [isOpen, setIsOpen] = useState(false);
16 const [position, setPosition] = useState({ top: 0, left: 0 });
17 const buttonRef = useRef<HTMLButtonElement>(null);
18
19 useEffect(() => {
20 if (isOpen && buttonRef.current) {
21 const rect = buttonRef.current.getBoundingClientRect();
22 setPosition({
23 top: rect.bottom + window.scrollY,
24 left: rect.left + window.scrollX,
25 });
26 }
27 }, [isOpen]);
28
29 return (
30 <>
31 <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
32 Toggle
33 </button>
34
35 {isOpen && createPortal(
36 <div
37 className="dropdown-menu"
38 style={{ position: 'absolute', ...position }}
39 >
40 {children}
41 </div>,
42 document.body
43 )}
44 </>
45 );
46}Modal Component#
1import { createPortal } from 'react-dom';
2import { useEffect, useRef } 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 overlayRef = useRef<HTMLDivElement>(null);
13
14 // Handle escape key
15 useEffect(() => {
16 const handleEscape = (e: KeyboardEvent) => {
17 if (e.key === 'Escape') onClose();
18 };
19
20 if (isOpen) {
21 document.addEventListener('keydown', handleEscape);
22 document.body.style.overflow = 'hidden';
23 }
24
25 return () => {
26 document.removeEventListener('keydown', handleEscape);
27 document.body.style.overflow = '';
28 };
29 }, [isOpen, onClose]);
30
31 // Handle click outside
32 const handleOverlayClick = (e: React.MouseEvent) => {
33 if (e.target === overlayRef.current) {
34 onClose();
35 }
36 };
37
38 if (!isOpen) return null;
39
40 return createPortal(
41 <div
42 ref={overlayRef}
43 className="modal-overlay"
44 onClick={handleOverlayClick}
45 role="dialog"
46 aria-modal="true"
47 aria-labelledby="modal-title"
48 >
49 <div className="modal-content">
50 <header className="modal-header">
51 {title && <h2 id="modal-title">{title}</h2>}
52 <button
53 onClick={onClose}
54 aria-label="Close modal"
55 className="modal-close"
56 >
57 ×
58 </button>
59 </header>
60 <div className="modal-body">
61 {children}
62 </div>
63 </div>
64 </div>,
65 document.body
66 );
67}Tooltip Component#
1import { createPortal } from 'react-dom';
2import { useState, useRef, useCallback } from 'react';
3
4interface TooltipProps {
5 content: React.ReactNode;
6 children: React.ReactElement;
7 position?: 'top' | 'bottom' | 'left' | 'right';
8}
9
10function Tooltip({ content, children, position = 'top' }: TooltipProps) {
11 const [isVisible, setIsVisible] = useState(false);
12 const [coords, setCoords] = useState({ top: 0, left: 0 });
13 const triggerRef = useRef<HTMLElement>(null);
14
15 const calculatePosition = useCallback(() => {
16 if (!triggerRef.current) return;
17
18 const rect = triggerRef.current.getBoundingClientRect();
19 const scrollX = window.scrollX;
20 const scrollY = window.scrollY;
21
22 const positions = {
23 top: {
24 top: rect.top + scrollY - 8,
25 left: rect.left + scrollX + rect.width / 2,
26 },
27 bottom: {
28 top: rect.bottom + scrollY + 8,
29 left: rect.left + scrollX + rect.width / 2,
30 },
31 left: {
32 top: rect.top + scrollY + rect.height / 2,
33 left: rect.left + scrollX - 8,
34 },
35 right: {
36 top: rect.top + scrollY + rect.height / 2,
37 left: rect.right + scrollX + 8,
38 },
39 };
40
41 setCoords(positions[position]);
42 }, [position]);
43
44 const handleMouseEnter = () => {
45 calculatePosition();
46 setIsVisible(true);
47 };
48
49 const handleMouseLeave = () => {
50 setIsVisible(false);
51 };
52
53 const trigger = React.cloneElement(children, {
54 ref: triggerRef,
55 onMouseEnter: handleMouseEnter,
56 onMouseLeave: handleMouseLeave,
57 onFocus: handleMouseEnter,
58 onBlur: handleMouseLeave,
59 });
60
61 return (
62 <>
63 {trigger}
64 {isVisible && createPortal(
65 <div
66 className={`tooltip tooltip-${position}`}
67 style={{
68 position: 'absolute',
69 top: coords.top,
70 left: coords.left,
71 transform: position === 'top' || position === 'bottom'
72 ? 'translateX(-50%)'
73 : position === 'top'
74 ? 'translateY(-100%)'
75 : undefined,
76 }}
77 role="tooltip"
78 >
79 {content}
80 </div>,
81 document.body
82 )}
83 </>
84 );
85}Notification Toast System#
1import { createPortal } from 'react-dom';
2import { createContext, useContext, useState, useCallback } from 'react';
3
4interface Toast {
5 id: string;
6 message: string;
7 type: 'success' | 'error' | 'info';
8}
9
10interface ToastContextType {
11 addToast: (message: string, type?: Toast['type']) => void;
12 removeToast: (id: string) => void;
13}
14
15const ToastContext = createContext<ToastContextType | null>(null);
16
17export function ToastProvider({ children }: { children: React.ReactNode }) {
18 const [toasts, setToasts] = useState<Toast[]>([]);
19
20 const addToast = useCallback((message: string, type: Toast['type'] = 'info') => {
21 const id = Date.now().toString();
22 setToasts(prev => [...prev, { id, message, type }]);
23
24 // Auto remove after 5 seconds
25 setTimeout(() => {
26 setToasts(prev => prev.filter(t => t.id !== id));
27 }, 5000);
28 }, []);
29
30 const removeToast = useCallback((id: string) => {
31 setToasts(prev => prev.filter(t => t.id !== id));
32 }, []);
33
34 return (
35 <ToastContext.Provider value={{ addToast, removeToast }}>
36 {children}
37 {createPortal(
38 <div className="toast-container" aria-live="polite">
39 {toasts.map(toast => (
40 <div
41 key={toast.id}
42 className={`toast toast-${toast.type}`}
43 role="alert"
44 >
45 {toast.message}
46 <button onClick={() => removeToast(toast.id)}>×</button>
47 </div>
48 ))}
49 </div>,
50 document.body
51 )}
52 </ToastContext.Provider>
53 );
54}
55
56export function useToast() {
57 const context = useContext(ToastContext);
58 if (!context) {
59 throw new Error('useToast must be used within ToastProvider');
60 }
61 return context;
62}Focus Management#
1import { createPortal } from 'react-dom';
2import { useEffect, useRef } from 'react';
3
4function FocusTrapModal({ isOpen, onClose, children }: ModalProps) {
5 const modalRef = useRef<HTMLDivElement>(null);
6 const previousActiveElement = useRef<HTMLElement | null>(null);
7
8 useEffect(() => {
9 if (isOpen) {
10 // Store current focus
11 previousActiveElement.current = document.activeElement as HTMLElement;
12
13 // Focus the modal
14 modalRef.current?.focus();
15
16 // Trap focus
17 const handleTab = (e: KeyboardEvent) => {
18 if (e.key !== 'Tab' || !modalRef.current) return;
19
20 const focusableElements = modalRef.current.querySelectorAll(
21 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
22 );
23 const firstElement = focusableElements[0] as HTMLElement;
24 const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
25
26 if (e.shiftKey && document.activeElement === firstElement) {
27 e.preventDefault();
28 lastElement.focus();
29 } else if (!e.shiftKey && document.activeElement === lastElement) {
30 e.preventDefault();
31 firstElement.focus();
32 }
33 };
34
35 document.addEventListener('keydown', handleTab);
36
37 return () => {
38 document.removeEventListener('keydown', handleTab);
39 // Restore focus
40 previousActiveElement.current?.focus();
41 };
42 }
43 }, [isOpen]);
44
45 if (!isOpen) return null;
46
47 return createPortal(
48 <div className="modal-overlay">
49 <div
50 ref={modalRef}
51 className="modal-content"
52 tabIndex={-1}
53 role="dialog"
54 aria-modal="true"
55 >
56 {children}
57 </div>
58 </div>,
59 document.body
60 );
61}Custom Portal Container#
1import { createPortal } from 'react-dom';
2import { useEffect, useState } from 'react';
3
4function usePortalContainer(id: string) {
5 const [container, setContainer] = useState<HTMLElement | null>(null);
6
7 useEffect(() => {
8 let element = document.getElementById(id);
9
10 if (!element) {
11 element = document.createElement('div');
12 element.id = id;
13 document.body.appendChild(element);
14 }
15
16 setContainer(element);
17
18 return () => {
19 // Only remove if empty
20 if (element && element.childNodes.length === 0) {
21 element.remove();
22 }
23 };
24 }, [id]);
25
26 return container;
27}
28
29function LayeredPortal({
30 children,
31 layer = 'modals',
32}: {
33 children: React.ReactNode;
34 layer?: 'modals' | 'tooltips' | 'notifications';
35}) {
36 const container = usePortalContainer(`portal-${layer}`);
37
38 if (!container) return null;
39
40 return createPortal(children, container);
41}
42
43// CSS to manage layers
44/*
45#portal-modals { z-index: 1000; }
46#portal-tooltips { z-index: 2000; }
47#portal-notifications { z-index: 3000; }
48*/Event Bubbling#
1// Events still bubble through React tree, not DOM tree
2function ParentComponent() {
3 const handleClick = (e: React.MouseEvent) => {
4 console.log('Parent clicked');
5 // This fires even though Modal is in document.body
6 };
7
8 return (
9 <div onClick={handleClick}>
10 <Modal isOpen={true}>
11 <button>Click me</button>
12 {/* Click bubbles to ParentComponent through React tree */}
13 </Modal>
14 </div>
15 );
16}
17
18// Stop propagation if needed
19function Modal({ children }: { children: React.ReactNode }) {
20 return createPortal(
21 <div onClick={(e) => e.stopPropagation()}>
22 {children}
23 </div>,
24 document.body
25 );
26}SSR Considerations#
1import { createPortal } from 'react-dom';
2import { useEffect, useState } from 'react';
3
4function ClientPortal({ children }: { children: React.ReactNode }) {
5 const [mounted, setMounted] = useState(false);
6
7 useEffect(() => {
8 setMounted(true);
9 }, []);
10
11 if (!mounted) return null;
12
13 return createPortal(children, document.body);
14}
15
16// Or check for document
17function SafePortal({ children }: { children: React.ReactNode }) {
18 if (typeof document === 'undefined') {
19 return null;
20 }
21
22 return createPortal(children, document.body);
23}Best Practices#
Accessibility:
✓ Manage focus properly
✓ Use correct ARIA attributes
✓ Handle keyboard navigation
✓ Restore focus on close
Performance:
✓ Don't overuse portals
✓ Clean up portal containers
✓ Lazy load modal content
✓ Use CSS animations
Architecture:
✓ Create reusable portal hooks
✓ Manage z-index consistently
✓ Handle SSR properly
✓ Consider event bubbling
UX:
✓ Block body scroll in modals
✓ Handle click outside
✓ Provide escape key handling
✓ Animate enter/exit
Conclusion#
React Portals solve DOM hierarchy issues for modals, tooltips, and overlays. They maintain React's event system while rendering outside the parent. Use them when CSS constraints prevent proper rendering, always manage focus and accessibility, and handle SSR appropriately.