Portals and DOM Rendering Control
1. createPortal for DOM Teleportation
| Feature | Syntax | Description | Use Case |
|---|---|---|---|
| createPortal | createPortal(child, container) |
Render children into different DOM node | Modals, tooltips, dropdowns outside parent DOM |
| Target Container | document.getElementById('id') |
DOM element to render portal into | Dedicated portal root, body, custom container |
| Event Bubbling | Bubbles through React tree | Events bubble to React parent, not DOM parent | Event handlers work as expected in React |
| Context | Inherits from React tree | Portal components access parent context | Theme, auth, state available in portals |
| Multiple Portals | Multiple createPortal calls | Render to different containers | Multiple modals, tooltips, overlays |
Example: Basic portal setup
import { createPortal } from 'react-dom';
// HTML structure needed
// <div id="root"></div>
// <div id="portal-root"></div>
function App() {
return (
<div className="app">
<h1>My App</h1>
<PortalExample />
</div>
);
}
function PortalExample() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>
Show Portal
</button>
{show && (
createPortal(
<div className="portal-content">
<h2>I'm rendered in #portal-root!</h2>
<button onClick={() => setShow(false)}>
Close
</button>
</div>,
document.getElementById('portal-root')
)
)}
</div>
);
}
// Portal component wrapper
function Portal({ children, container = 'portal-root' }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
const portalRoot = document.getElementById(container);
if (!portalRoot) {
console.error(`Portal container #${container} not found`);
return null;
}
return createPortal(children, portalRoot);
}
Example: Dynamic portal container
// Create portal container on demand
function usePortal(id = 'portal-root') {
const [container, setContainer] = useState(null);
useEffect(() => {
// Check if container exists
let element = document.getElementById(id);
// Create if doesn't exist
if (!element) {
element = document.createElement('div');
element.id = id;
document.body.appendChild(element);
}
setContainer(element);
// Cleanup - remove if we created it
return () => {
if (element && element.childNodes.length === 0) {
element.remove();
}
};
}, [id]);
return container;
}
// Usage
function MyComponent() {
const portalContainer = usePortal('my-portal');
if (!portalContainer) return null;
return createPortal(
<div>Portal content</div>,
portalContainer
);
}
Portal Benefits: Render outside parent DOM hierarchy, avoid CSS overflow/z-index issues,
maintain React component tree structure, events bubble through React tree, access parent context and props.
2. Modal Components with Portals
| Feature | Implementation | Description | Benefit |
|---|---|---|---|
| Overlay/Backdrop | Full-screen div with fixed position | Semi-transparent background behind modal | Focus attention, prevent background interaction |
| Body Scroll Lock | document.body.style.overflow |
Disable scrolling when modal open | Keep user focused on modal content |
| Focus Trap | Manage focus within modal | Tab cycles through modal elements only | Keyboard accessibility, prevents focus escape |
| ESC to Close | Keyboard event listener | Close modal on Escape key | Expected behavior, better UX |
| Click Outside | Backdrop click handler | Close modal when clicking backdrop | Intuitive dismissal, flexible UX |
Example: Full-featured modal with portal
import { createPortal } from 'react-dom';
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children, title }) {
const modalRef = useRef(null);
// Lock body scroll when modal open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}
}, [isOpen]);
// Close on ESC key
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Focus trap
useEffect(() => {
if (!isOpen || !modalRef.current) return;
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus first element
firstElement?.focus();
const handleTab = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
className="modal-content"
onClick={(e) => e.stopPropagation()} // Prevent close on content click
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="modal-close"
>
×
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
}
// Usage
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<button onClick={() => setIsModalOpen(true)}>
Open Modal
</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="My Modal"
>
<p>Modal content goes here</p>
<button onClick={() => setIsModalOpen(false)}>
Close
</button>
</Modal>
</div>
);
}
Example: Modal with animation
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
function AnimatedModal({ isOpen, onClose, children }) {
const [shouldRender, setShouldRender] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
// Trigger animation after render
setTimeout(() => setIsAnimating(true), 10);
} else {
setIsAnimating(false);
// Remove from DOM after animation
setTimeout(() => setShouldRender(false), 300);
}
}, [isOpen]);
if (!shouldRender) return null;
return createPortal(
<div
className={`modal-overlay ${isAnimating ? 'open' : ''}`}
onClick={onClose}
>
<div
className={`modal-content ${isAnimating ? 'open' : ''}`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body
);
}
/* CSS for animation
.modal-overlay {
opacity: 0;
transition: opacity 300ms ease-in-out;
}
.modal-overlay.open {
opacity: 1;
}
.modal-content {
transform: scale(0.7);
opacity: 0;
transition: all 300ms ease-in-out;
}
.modal-content.open {
transform: scale(1);
opacity: 1;
}
*/
Warning: Always lock body scroll, implement keyboard accessibility (ESC, focus trap), use
proper ARIA attributes, clean up event listeners, test with screen readers.
3. Tooltip and Overlay Positioning
| Technique | Implementation | Description | Use Case |
|---|---|---|---|
| getBoundingClientRect | Element position calculation | Get trigger element position and size | Position tooltip relative to trigger |
| Positioning Library | Popper.js, Floating UI | Smart positioning with collision detection | Complex tooltips, dropdowns, popovers |
| Dynamic Position | Calculate on show/scroll/resize | Update position when trigger moves | Scrollable containers, responsive layouts |
| Flip/Shift | Adjust if outside viewport | Prevent tooltip from being cut off | Edges of screen, small viewports |
| Arrow Positioning | Calculated arrow pointer position | Arrow points to trigger element | Visual connection between elements |
Example: Basic tooltip with positioning
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
function Tooltip({ children, content, position = 'top' }) {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const calculatePosition = () => {
if (!triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
let top = 0;
let left = 0;
const gap = 8; // Space between trigger and tooltip
switch (position) {
case 'top':
top = triggerRect.top - tooltipRect.height - gap;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + gap;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - gap;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + gap;
break;
}
// Prevent overflow
top = Math.max(gap, Math.min(top, window.innerHeight - tooltipRect.height - gap));
left = Math.max(gap, Math.min(left, window.innerWidth - tooltipRect.width - gap));
setCoords({ top, left });
};
useEffect(() => {
if (isVisible) {
calculatePosition();
window.addEventListener('scroll', calculatePosition);
window.addEventListener('resize', calculatePosition);
return () => {
window.removeEventListener('scroll', calculatePosition);
window.removeEventListener('resize', calculatePosition);
};
}
}, [isVisible]);
return (
<>
<span
ref={triggerRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</span>
{isVisible && createPortal(
<div
ref={tooltipRef}
className="tooltip"
role="tooltip"
style={{
position: 'fixed',
top: `${coords.top}px`,
left: `${coords.left}px`,
zIndex: 9999
}}
>
{content}
</div>,
document.body
)}
</>
);
}
// Usage
<p>
Hover over <Tooltip content="This is a tooltip!">
<strong>this text</strong>
</Tooltip> to see tooltip.
</p>
Example: Advanced positioning with Floating UI
import { useFloating, flip, shift, offset, arrow } from '@floating-ui/react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
function AdvancedTooltip({ children, content }) {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = useRef(null);
const { x, y, strategy, refs, middlewareData } = useFloating({
open: isOpen,
placement: 'top',
middleware: [
offset(8), // Gap between trigger and tooltip
flip(), // Flip to opposite side if no space
shift({ padding: 8 }), // Shift to stay in viewport
arrow({ element: arrowRef }) // Position arrow
]
});
const arrowX = middlewareData.arrow?.x;
const arrowY = middlewareData.arrow?.y;
return (
<>
<span
ref={refs.setReference}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{children}
</span>
{isOpen && createPortal(
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
zIndex: 9999
}}
className="tooltip"
>
{content}
<div
ref={arrowRef}
style={{
position: 'absolute',
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
transform: 'rotate(45deg)'
}}
className="tooltip-arrow"
/>
</div>,
document.body
)}
</>
);
}
Positioning Best Practices: Use positioning libraries for complex cases, recalculate on
scroll/resize, prevent viewport overflow, add collision detection, position arrow correctly, test on mobile
devices.
4. Event Bubbling and Portal Event Handling
| Concept | Behavior | Description | Implication |
|---|---|---|---|
| React Tree Bubbling | Events bubble through React tree | Not DOM tree - React component hierarchy | Parent handlers work even though DOM separate |
| onClick Propagation | Bubbles to React parent | Portal click triggers parent onClick | Event handlers work naturally |
| stopPropagation | e.stopPropagation() |
Stop event from bubbling in React tree | Prevent parent handlers from firing |
| Context Access | Portal accesses parent context | Context flows through React tree | Portal components have full context access |
| Native Events | Don't bubble through portals | addEventListener on DOM doesn't cross portal | Use React events, not native listeners |
Example: Event bubbling demonstration
import { createPortal } from 'react-dom';
function EventBubblingDemo() {
const [log, setLog] = useState([]);
const addLog = (message) => {
setLog(prev => [...prev, message]);
};
return (
<div
onClick={() => addLog('Parent clicked')}
style={{ padding: '20px', border: '2px solid blue' }}
>
<h3>Parent Component</h3>
<button onClick={() => addLog('Regular button clicked')}>
Regular Button (in DOM tree)
</button>
{createPortal(
<div
style={{
position: 'fixed',
top: '100px',
right: '20px',
padding: '20px',
border: '2px solid red',
background: 'white'
}}
>
<h3>Portal Component</h3>
<p>I'm rendered outside the parent DOM!</p>
{/* This click WILL bubble to parent in React tree */}
<button onClick={() => addLog('Portal button clicked')}>
Portal Button
</button>
{/* This prevents bubbling */}
<button onClick={(e) => {
e.stopPropagation();
addLog('Portal button (no bubble) clicked');
}}>
Portal Button (stopPropagation)
</button>
</div>,
document.body
)}
<div style={{ marginTop: '20px' }}>
<h4>Event Log:</h4>
<ul>
{log.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
);
}
// Result: Portal button click triggers BOTH:
// 1. "Portal button clicked"
// 2. "Parent clicked" (bubbled through React tree)
// But "Portal button (no bubble)" only triggers:
// 1. "Portal button (no bubble) clicked"
// (stopPropagation prevents parent handler)
Example: Context access in portals
import { createContext, useContext } from 'react';
import { createPortal } from 'react-dom';
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<div className={`app ${theme}`}>
<h1>Main App</h1>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
<PortalComponent />
</div>
</ThemeContext.Provider>
);
}
function PortalComponent() {
// Portal component can access parent context!
const theme = useContext(ThemeContext);
return createPortal(
<div className={`portal ${theme}`}>
<h2>I'm in a portal</h2>
<p>Current theme: {theme}</p>
<p>Context works across portals!</p>
</div>,
document.body
);
}
// Theme updates in parent flow to portal component
// Even though rendered in different DOM location
Event Bubbling Key Points: React events bubble through component tree not DOM tree, portal
events reach parent handlers naturally, use stopPropagation to prevent bubbling, context and props work across
portals, native DOM events don't cross portal boundary.
5. Accessibility Considerations with Portals
| Concern | Solution | ARIA Attribute | Why Important |
|---|---|---|---|
| Screen Reader Announcement | Use proper role attributes | role="dialog" |
Announce modal/dialog to screen readers |
| Modal State | Indicate modal is active | aria-modal="true" |
Screen reader understands context |
| Label Association | Link title to dialog | aria-labelledby |
Identify dialog purpose |
| Focus Management | Move focus to modal | Focus first element | Keyboard users start in modal |
| Focus Trap | Keep focus within modal | Tab cycling | Prevent accessing hidden content |
| Return Focus | Restore focus after close | Store trigger element | User returns to where they were |
| Hidden Content | Mark background as hidden | aria-hidden="true" |
Screen readers skip background |
Example: Accessible modal with full ARIA support
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const triggerRef = useRef(document.activeElement);
const mainContentRef = useRef(document.getElementById('main-content'));
// Focus management
useEffect(() => {
if (!isOpen) return;
// Store trigger element
triggerRef.current = document.activeElement;
// Hide main content from screen readers
if (mainContentRef.current) {
mainContentRef.current.setAttribute('aria-hidden', 'true');
mainContentRef.current.setAttribute('inert', ''); // Prevent interaction
}
// Focus first focusable element in modal
const focusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
return () => {
// Restore main content
if (mainContentRef.current) {
mainContentRef.current.removeAttribute('aria-hidden');
mainContentRef.current.removeAttribute('inert');
}
// Return focus to trigger
if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
}
};
}, [isOpen]);
// ESC and focus trap
useEffect(() => {
if (!isOpen || !modalRef.current) return;
const handleKeyDown = (e) => {
// Close on ESC
if (e.key === 'Escape') {
onClose();
return;
}
// Focus trap
if (e.key === 'Tab') {
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
className="modal-overlay"
onClick={onClose}
role="presentation"
>
<div
ref={modalRef}
className="modal-content"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="modal-close"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div id="modal-description" className="modal-body">
{children}
</div>
</div>
</div>,
document.body
);
}
Example: Accessible tooltip
function AccessibleTooltip({ children, content, id }) {
const [isVisible, setIsVisible] = useState(false);
const tooltipId = id || `tooltip-${Math.random().toString(36).substr(2, 9)}`;
return (
<>
<span
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
aria-describedby={isVisible ? tooltipId : undefined}
tabIndex={0} // Make keyboard accessible
>
{children}
</span>
{isVisible && createPortal(
<div
id={tooltipId}
role="tooltip"
className="tooltip"
style={{
position: 'fixed',
// ... positioning logic
}}
>
{content}
</div>,
document.body
)}
</>
);
}
// ARIA attributes explained:
// - aria-describedby: Links tooltip to trigger
// - role="tooltip": Identifies as tooltip
// - tabIndex={0}: Makes keyboard accessible
Accessibility Requirements: Always manage focus properly, implement keyboard navigation (ESC,
Tab), use proper ARIA attributes, hide background content, return focus on close, test with screen readers
(NVDA, JAWS, VoiceOver), support keyboard-only navigation.
6. Multiple Portal Management Patterns
| Pattern | Implementation | Description | Use Case |
|---|---|---|---|
| Portal Stack | Array of active portals | Track z-index order of portals | Multiple modals, layered overlays |
| Portal Manager | Context-based management | Centralized portal state and control | Complex apps with many portals |
| Modal Queue | Queue system for modals | Show modals sequentially | Onboarding flows, tutorials |
| Z-Index Management | Auto-increment z-index | Ensure correct stacking order | Prevent overlap issues |
| Cleanup on Unmount | Remove portal containers | Clean up DOM when done | Prevent memory leaks, clean DOM |
Example: Portal manager with context
import { createContext, useContext, useState } from 'react';
import { createPortal } from 'react-dom';
// Portal manager context
const PortalContext = createContext(null);
export function PortalProvider({ children }) {
const [portals, setPortals] = useState([]);
const addPortal = (portal) => {
const id = Math.random().toString(36).substr(2, 9);
setPortals(prev => [...prev, { ...portal, id, zIndex: 1000 + prev.length }]);
return id;
};
const removePortal = (id) => {
setPortals(prev => prev.filter(p => p.id !== id));
};
const closeTopPortal = () => {
setPortals(prev => prev.slice(0, -1));
};
return (
<PortalContext.Provider value={{ portals, addPortal, removePortal, closeTopPortal }}>
{children}
{portals.map(portal =>
createPortal(
<div key={portal.id} style={{ zIndex: portal.zIndex }}>
{portal.content}
</div>,
document.body
)
)}
</PortalContext.Provider>
);
}
// Hook to use portal manager
export function usePortal() {
const context = useContext(PortalContext);
if (!context) {
throw new Error('usePortal must be used within PortalProvider');
}
return context;
}
// Modal component using portal manager
function ManagedModal({ isOpen, onClose, children, title }) {
const { addPortal, removePortal } = usePortal();
const portalIdRef = useRef(null);
useEffect(() => {
if (isOpen) {
portalIdRef.current = addPortal({
content: (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>{title}</h2>
{children}
</div>
</div>
)
});
} else if (portalIdRef.current) {
removePortal(portalIdRef.current);
portalIdRef.current = null;
}
return () => {
if (portalIdRef.current) {
removePortal(portalIdRef.current);
}
};
}, [isOpen, title, children]);
return null;
}
// Usage
function App() {
return (
<PortalProvider>
<MyApp />
</PortalProvider>
);
}
Example: Modal queue system
function useModalQueue() {
const [queue, setQueue] = useState([]);
const [currentModal, setCurrentModal] = useState(null);
const addToQueue = (modal) => {
setQueue(prev => [...prev, modal]);
};
const showNext = () => {
if (queue.length > 0) {
setCurrentModal(queue[0]);
setQueue(prev => prev.slice(1));
} else {
setCurrentModal(null);
}
};
const closeModal = () => {
setCurrentModal(null);
// Show next in queue after delay
setTimeout(showNext, 300);
};
// Auto-show first modal
useEffect(() => {
if (!currentModal && queue.length > 0) {
showNext();
}
}, [queue, currentModal]);
return { currentModal, addToQueue, closeModal, queueLength: queue.length };
}
// Usage - onboarding flow
function OnboardingFlow() {
const { currentModal, addToQueue, closeModal } = useModalQueue();
useEffect(() => {
// Queue multiple modals
addToQueue({ title: 'Welcome', content: 'Step 1...' });
addToQueue({ title: 'Features', content: 'Step 2...' });
addToQueue({ title: 'Get Started', content: 'Step 3...' });
}, []);
return (
<>
{currentModal && (
<Modal
isOpen={true}
onClose={closeModal}
title={currentModal.title}
>
{currentModal.content}
</Modal>
)}
</>
);
}
Multiple Portal Best Practices: Use portal manager for complex apps, implement z-index
management, handle ESC key to close top portal, clean up portals on unmount, consider modal queue for flows,
test stacking behavior thoroughly.