React Portals render children into a different DOM node while preserving React's component hierarchy. Here's how to use them.
Basic Portal#
1import { createPortal } from 'react-dom';
2
3function Modal({ children }: { children: React.ReactNode }) {
4 return createPortal(
5 <div className="modal-overlay">
6 <div className="modal-content">
7 {children}
8 </div>
9 </div>,
10 document.body
11 );
12}
13
14// Usage
15function App() {
16 const [showModal, setShowModal] = useState(false);
17
18 return (
19 <div>
20 <button onClick={() => setShowModal(true)}>Open Modal</button>
21 {showModal && (
22 <Modal>
23 <h2>Modal Content</h2>
24 <button onClick={() => setShowModal(false)}>Close</button>
25 </Modal>
26 )}
27 </div>
28 );
29}Portal Container Hook#
1import { useEffect, useState } from 'react';
2import { createPortal } from 'react-dom';
3
4function usePortal(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 we created it and it's empty
20 if (element && element.childNodes.length === 0) {
21 element.remove();
22 }
23 };
24 }, [id]);
25
26 return container;
27}
28
29// Usage
30function Portal({ children, id = 'portal-root' }: {
31 children: React.ReactNode;
32 id?: string;
33}) {
34 const container = usePortal(id);
35
36 if (!container) return null;
37
38 return createPortal(children, container);
39}Modal with Overlay#
1import { useEffect, useCallback } from 'react';
2import { createPortal } from 'react-dom';
3
4interface ModalProps {
5 isOpen: boolean;
6 onClose: () => void;
7 children: React.ReactNode;
8 closeOnOverlayClick?: boolean;
9}
10
11function Modal({
12 isOpen,
13 onClose,
14 children,
15 closeOnOverlayClick = true,
16}: ModalProps) {
17 // Handle escape key
18 useEffect(() => {
19 const handleEscape = (e: KeyboardEvent) => {
20 if (e.key === 'Escape') {
21 onClose();
22 }
23 };
24
25 if (isOpen) {
26 document.addEventListener('keydown', handleEscape);
27 document.body.style.overflow = 'hidden';
28 }
29
30 return () => {
31 document.removeEventListener('keydown', handleEscape);
32 document.body.style.overflow = '';
33 };
34 }, [isOpen, onClose]);
35
36 const handleOverlayClick = useCallback(
37 (e: React.MouseEvent) => {
38 if (closeOnOverlayClick && e.target === e.currentTarget) {
39 onClose();
40 }
41 },
42 [closeOnOverlayClick, onClose]
43 );
44
45 if (!isOpen) return null;
46
47 return createPortal(
48 <div
49 className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
50 onClick={handleOverlayClick}
51 role="dialog"
52 aria-modal="true"
53 >
54 <div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
55 {children}
56 </div>
57 </div>,
58 document.body
59 );
60}Tooltip Portal#
1import { useState, useRef, useEffect } from 'react';
2import { createPortal } from 'react-dom';
3
4interface TooltipProps {
5 content: React.ReactNode;
6 children: React.ReactElement;
7}
8
9function Tooltip({ content, children }: TooltipProps) {
10 const [isVisible, setIsVisible] = useState(false);
11 const [position, setPosition] = useState({ top: 0, left: 0 });
12 const triggerRef = useRef<HTMLElement>(null);
13
14 useEffect(() => {
15 if (isVisible && triggerRef.current) {
16 const rect = triggerRef.current.getBoundingClientRect();
17 setPosition({
18 top: rect.top - 8,
19 left: rect.left + rect.width / 2,
20 });
21 }
22 }, [isVisible]);
23
24 const trigger = React.cloneElement(children, {
25 ref: triggerRef,
26 onMouseEnter: () => setIsVisible(true),
27 onMouseLeave: () => setIsVisible(false),
28 onFocus: () => setIsVisible(true),
29 onBlur: () => setIsVisible(false),
30 });
31
32 return (
33 <>
34 {trigger}
35 {isVisible &&
36 createPortal(
37 <div
38 className="fixed z-50 px-2 py-1 bg-gray-900 text-white text-sm rounded transform -translate-x-1/2 -translate-y-full"
39 style={{ top: position.top, left: position.left }}
40 role="tooltip"
41 >
42 {content}
43 </div>,
44 document.body
45 )}
46 </>
47 );
48}Dropdown Portal#
1import { useState, useRef, useEffect } from 'react';
2import { createPortal } from 'react-dom';
3
4interface DropdownProps {
5 trigger: React.ReactNode;
6 children: React.ReactNode;
7}
8
9function Dropdown({ trigger, children }: DropdownProps) {
10 const [isOpen, setIsOpen] = useState(false);
11 const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
12 const triggerRef = useRef<HTMLButtonElement>(null);
13 const dropdownRef = useRef<HTMLDivElement>(null);
14
15 useEffect(() => {
16 if (isOpen && triggerRef.current) {
17 const rect = triggerRef.current.getBoundingClientRect();
18 setPosition({
19 top: rect.bottom + 4,
20 left: rect.left,
21 width: rect.width,
22 });
23 }
24 }, [isOpen]);
25
26 useEffect(() => {
27 const handleClickOutside = (e: MouseEvent) => {
28 if (
29 dropdownRef.current &&
30 !dropdownRef.current.contains(e.target as Node) &&
31 !triggerRef.current?.contains(e.target as Node)
32 ) {
33 setIsOpen(false);
34 }
35 };
36
37 if (isOpen) {
38 document.addEventListener('mousedown', handleClickOutside);
39 }
40
41 return () => {
42 document.removeEventListener('mousedown', handleClickOutside);
43 };
44 }, [isOpen]);
45
46 return (
47 <>
48 <button
49 ref={triggerRef}
50 onClick={() => setIsOpen(!isOpen)}
51 aria-expanded={isOpen}
52 aria-haspopup="true"
53 >
54 {trigger}
55 </button>
56 {isOpen &&
57 createPortal(
58 <div
59 ref={dropdownRef}
60 className="fixed z-50 bg-white border rounded-lg shadow-lg"
61 style={{
62 top: position.top,
63 left: position.left,
64 minWidth: position.width,
65 }}
66 >
67 {children}
68 </div>,
69 document.body
70 )}
71 </>
72 );
73}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
10interface ToastContextValue {
11 addToast: (message: string, type: Toast['type']) => void;
12 removeToast: (id: string) => void;
13}
14
15const ToastContext = createContext<ToastContextValue | null>(null);
16
17function ToastProvider({ children }: { children: React.ReactNode }) {
18 const [toasts, setToasts] = useState<Toast[]>([]);
19
20 const addToast = useCallback((message: string, type: Toast['type']) => {
21 const id = crypto.randomUUID();
22 setToasts((prev) => [...prev, { id, message, type }]);
23
24 setTimeout(() => {
25 setToasts((prev) => prev.filter((t) => t.id !== id));
26 }, 5000);
27 }, []);
28
29 const removeToast = useCallback((id: string) => {
30 setToasts((prev) => prev.filter((t) => t.id !== id));
31 }, []);
32
33 return (
34 <ToastContext.Provider value={{ addToast, removeToast }}>
35 {children}
36 {createPortal(
37 <div className="fixed bottom-4 right-4 space-y-2 z-50">
38 {toasts.map((toast) => (
39 <div
40 key={toast.id}
41 className={`p-4 rounded-lg shadow-lg ${
42 toast.type === 'success' ? 'bg-green-500' :
43 toast.type === 'error' ? 'bg-red-500' : 'bg-blue-500'
44 } text-white`}
45 >
46 {toast.message}
47 <button onClick={() => removeToast(toast.id)}>×</button>
48 </div>
49 ))}
50 </div>,
51 document.body
52 )}
53 </ToastContext.Provider>
54 );
55}
56
57function useToast() {
58 const context = useContext(ToastContext);
59 if (!context) throw new Error('useToast must be used within ToastProvider');
60 return context;
61}Focus Trap#
1import { useEffect, useRef } from 'react';
2import { createPortal } from 'react-dom';
3
4function FocusTrap({ children }: { children: React.ReactNode }) {
5 const containerRef = useRef<HTMLDivElement>(null);
6
7 useEffect(() => {
8 const container = containerRef.current;
9 if (!container) return;
10
11 const focusableElements = container.querySelectorAll(
12 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
13 );
14
15 const firstElement = focusableElements[0] as HTMLElement;
16 const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
17
18 firstElement?.focus();
19
20 const handleTab = (e: KeyboardEvent) => {
21 if (e.key !== 'Tab') return;
22
23 if (e.shiftKey) {
24 if (document.activeElement === firstElement) {
25 e.preventDefault();
26 lastElement?.focus();
27 }
28 } else {
29 if (document.activeElement === lastElement) {
30 e.preventDefault();
31 firstElement?.focus();
32 }
33 }
34 };
35
36 container.addEventListener('keydown', handleTab);
37 return () => container.removeEventListener('keydown', handleTab);
38 }, []);
39
40 return <div ref={containerRef}>{children}</div>;
41}
42
43// Usage with Modal
44function AccessibleModal({ isOpen, onClose, children }: ModalProps) {
45 if (!isOpen) return null;
46
47 return createPortal(
48 <FocusTrap>
49 <div className="modal-overlay" role="dialog" aria-modal="true">
50 <div className="modal-content">
51 {children}
52 </div>
53 </div>
54 </FocusTrap>,
55 document.body
56 );
57}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 <h1>Parent</h1>
10 <PortalChild />
11 </div>
12 );
13}
14
15function PortalChild() {
16 return createPortal(
17 <button onClick={() => console.log('Button clicked')}>
18 Click me (in portal)
19 </button>,
20 document.body
21 );
22}
23
24// Clicking button logs:
25// "Button clicked"
26// "Parent clicked" (event bubbles in React tree)SSR Considerations#
1import { useEffect, useState } from 'react';
2import { createPortal } from 'react-dom';
3
4function ClientOnlyPortal({ 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 with a selector
17function SafePortal({
18 children,
19 selector = '#portal-root',
20}: {
21 children: React.ReactNode;
22 selector?: string;
23}) {
24 const [container, setContainer] = useState<Element | null>(null);
25
26 useEffect(() => {
27 setContainer(document.querySelector(selector));
28 }, [selector]);
29
30 if (!container) return null;
31
32 return createPortal(children, container);
33}Best Practices#
Use Cases:
✓ Modals and dialogs
✓ Tooltips and popovers
✓ Dropdown menus
✓ Toast notifications
Accessibility:
✓ Implement focus trapping
✓ Add ARIA attributes
✓ Handle keyboard navigation
✓ Manage focus on open/close
Styling:
✓ Use fixed positioning
✓ Handle z-index properly
✓ Consider scroll locking
✓ Position relative to trigger
Avoid:
✗ Overusing portals
✗ Breaking accessibility
✗ Memory leaks (cleanup)
✗ Ignoring SSR
Conclusion#
React Portals render content outside the DOM hierarchy while maintaining React context and event bubbling. Use them for modals, tooltips, dropdowns, and any UI that needs to break out of container overflow or z-index constraints. Always implement proper accessibility with focus management, keyboard handling, and ARIA attributes. Handle SSR by checking for document availability before rendering.