React Portals
Creating portals, use cases (modals, tooltips), event bubbling through portals..
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓React 16+, Node.js 14+, basic understanding of React components, hooks, and the DOM. Familiarity with event bubbling and accessibility (ARIA) is helpful.
React Portals: A core React concept for building modern user interfaces. It helps you structure your components efficiently and handle data flow predictably.
React Portals is a fundamental concept in React development. It refers to the patterns, APIs, and best practices that React provides for building user interfaces. Understanding this concept is essential for writing clean, efficient, and maintainable React code. This tutorial covers everything from basic usage to advanced patterns with real-world examples.
React is a JavaScript library for building user interfaces. This article covers portals — a key concept for building modern web applications.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You've built a modal. It works fine in dev. Then you ship it, and the z-index war begins. The overlay bleeds under the header, the close button is unclickable, and your CEO's screenshot makes the rounds on Slack. The root cause? Your modal is trapped inside a parent with overflow: hidden or a low stacking context. React Portals exist to solve exactly this: render a component's markup anywhere in the DOM tree while keeping the React tree intact. No more fighting CSS battles you can't win. Portals let you break out of layout constraints without breaking component logic. If you're not using them for modals, tooltips, dropdowns, or any UI that needs to escape a container, you're writing brittle code. Let's fix that.
What Are React Portals?
React Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component. Introduced in React 16, they solve a fundamental problem: sometimes you need to render something physically elsewhere in the DOM, but logically keep it inside the React component tree. The canonical use case is a modal: you want the modal's markup to be a direct child of <body> to avoid CSS clipping and z-index issues, but you want the modal component to receive props, context, and event bubbling as if it were a normal child. Portals are created using ReactDOM.createPortal(child, container). The child is any renderable React element, and container is a DOM element (usually a div appended to document.body). The portal behaves like a normal React child in terms of context and event propagation, but the actual DOM node lives elsewhere. This is not a hack—it's a deliberate API that acknowledges the mismatch between React's virtual DOM and the real DOM's layout constraints.
import React from 'react'; import ReactDOM from 'react-dom'; const Portal = ({ children }) => { const container = document.getElementById('portal-root'); return ReactDOM.createPortal(children, container); }; export default Portal;
<div id="portal-root"></div> in your public/index.html.Event Bubbling Through Portals
A common misconception is that because a portal renders its children in a different DOM location, events won't bubble up to the parent React tree. That's false. React maintains a virtual event system that bubbles events according to the React component hierarchy, not the DOM hierarchy. So if you have a button inside a portal, and you click it, the onClick handler on the portal's parent component will fire—even though the button's DOM node is a child of <body>. This is critical for building things like dropdown menus that need to close when you click outside. You can attach a click handler to the portal's parent, and it will correctly detect clicks inside the portal. However, native DOM events (like those from third-party libraries) do follow the DOM tree, so be careful when mixing. For most React apps, this behavior is exactly what you want: it keeps component logic predictable while giving you DOM flexibility.
import React, { useState } from 'react'; import Portal from './Portal'; const Parent = () => { const [clicks, setClicks] = useState(0); const handleClick = () => { setClicks(c => c + 1); console.log('Parent click handler fired'); }; return ( <div onClick={handleClick}> <p>Clicks: {clicks}</p> <Portal> <button>Click me inside portal</button> </Portal> </div> ); }; export default Parent;
Setting Up a Portal Root
Before you can use portals, you need a DOM node to render into. The simplest approach is to add a <div id="portal-root"></div> in your HTML template, typically right before the closing </body> tag. But in production, you might want to create it dynamically to avoid modifying the HTML. You can do this in a custom hook or in the component that uses the portal. The key is to ensure the container exists before calling createPortal. If you create it dynamically, do it in a useEffect or a ref callback to avoid race conditions. Also, consider accessibility: screen readers navigate the DOM tree, so placing content far from its logical parent can confuse users. Use aria-* attributes and ensure focus management is handled. For modals, you should trap focus inside the portal and restore focus on close. The portal root should be a sibling of your app root, not a child, to avoid inheriting unwanted CSS.
import React, { useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; const usePortal = () => { const portalRef = useRef(null); useEffect(() => { const div = document.createElement('div'); div.setAttribute('id', 'dynamic-portal'); document.body.appendChild(div); portalRef.current = div; return () => { document.body.removeChild(div); }; }, []); return portalRef; }; const Modal = ({ children }) => { const portal = usePortal(); if (!portal.current) return null; return ReactDOM.createPortal(children, portal.current); }; export default Modal;
Building a Production Modal with Portal
Let's build a modal that's production-ready. It should: render via portal to avoid z-index issues, trap focus, close on Escape key, close on overlay click, and manage body scroll. We'll use a ref for the modal content to trap focus, and a useEffect for keyboard events. The overlay click handler checks if the click target is the overlay itself (not a child) to close. Focus trapping is essential for accessibility: when the modal opens, focus moves to the first focusable element; when it closes, focus returns to the element that triggered it. We'll also add aria-modal="true" and role="dialog" for screen readers. This modal is a drop-in replacement for most UI libraries, but without the bloat.
import React, { useEffect, useRef, useCallback } from 'react'; import ReactDOM from 'react-dom'; const Modal = ({ isOpen, onClose, children }) => { const overlayRef = useRef(); const contentRef = useRef(); const previousFocusRef = useRef(); const handleKeyDown = useCallback((e) => { if (e.key === 'Escape') onClose(); if (e.key === 'Tab') { const focusable = contentRef.current.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }, [onClose]); useEffect(() => { if (isOpen) { previousFocusRef.current = document.activeElement; document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; // Focus first focusable element const focusable = contentRef.current?.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); focusable?.focus(); } return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; previousFocusRef.current?.focus(); }; }, [isOpen, handleKeyDown]); if (!isOpen) return null; return ReactDOM.createPortal( <div ref={overlayRef} onClick={(e) => { if (e.target === overlayRef.current) onClose(); }} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} role="dialog" aria-modal="true" > <div ref={contentRef} style={{ background: '#fff', padding: 20, borderRadius: 8, minWidth: 300 }}> {children} <button onClick={onClose}>Close</button> </div> </div>, document.getElementById('portal-root') ); }; export default Modal;
Portals for Tooltips and Dropdowns
Modals aren't the only use case. Tooltips and dropdowns often get clipped by overflow: hidden parents. A portal can render them outside the clipping context. For tooltips, you need to position them relative to a trigger element. You can get the trigger's bounding rect via a ref, then set the portal's position using absolute or fixed positioning. The challenge is re-positioning on scroll or resize. You can use IntersectionObserver or listen to scroll events, but be careful with performance. A simpler approach is to use a library like Popper.js, but if you want to keep it lightweight, a custom hook that updates position on every render (using useLayoutEffect) works for most cases. For dropdowns, you also need to handle click outside to close. Since the portal is outside the parent DOM, you can attach a click handler to document and check if the click is inside the portal content. This pattern is robust and avoids the z-index wars.
import React, { useState, useRef, useEffect } from 'react'; import ReactDOM from 'react-dom'; const Tooltip = ({ text, children }) => { const [visible, setVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); const triggerRef = useRef(); const show = () => { const rect = triggerRef.current.getBoundingClientRect(); setPosition({ top: rect.bottom + 8, left: rect.left + rect.width / 2 }); setVisible(true); }; const hide = () => setVisible(false); return ( <> <span ref={triggerRef} onMouseEnter={show} onMouseLeave={hide}> {children} </span> {visible && ReactDOM.createPortal( <div style={{ position: 'fixed', top: position.top, left: position.left, transform: 'translateX(-50%)', background: '#333', color: '#fff', padding: '4px 8px', borderRadius: 4, whiteSpace: 'nowrap', zIndex: 9999 }}> {text} </div>, document.getElementById('portal-root') )} </> ); }; export default Tooltip;
Portals and Context API
One of the most powerful features of portals is that they preserve React context. If you have a ThemeProvider or a Redux Provider at the top of your app, any portal child can still access that context. This is because context flows through the React component tree, not the DOM tree. So a modal rendered via portal can use useContext(ThemeContext) and get the correct theme. This is a huge advantage over appending raw DOM elements. However, be aware that if you use a third-party library that manipulates the DOM directly (like some chart libraries), context won't flow into those elements. In that case, you need to wrap the library's output in a React component that consumes context and passes it down. Also, if you have multiple React roots (e.g., micro-frontends), context won't cross root boundaries. For most single-root apps, portals are transparent to context.
import React, { useContext } from 'react'; import ReactDOM from 'react-dom'; const ThemeContext = React.createContext('light'); const ThemedButton = () => { const theme = useContext(ThemeContext); return <button style={{ background: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#000' }}>Themed Button</button>; }; const App = () => { return ( <ThemeContext.Provider value="dark"> <div> <p>Outside portal</p> {ReactDOM.createPortal(<ThemedButton />, document.getElementById('portal-root'))} </div> </ThemeContext.Provider> ); }; export default App;
Error Boundaries and Portals
Error boundaries catch JavaScript errors in their child component tree. Since portals are children in the React tree, errors thrown inside a portal are caught by the nearest error boundary above the portal in the component hierarchy. This is important: if your modal crashes, you want the error boundary to catch it and show a fallback UI, not crash the whole app. However, because the portal's DOM is elsewhere, the error boundary's fallback might render in the wrong place. To handle this, you can wrap the portal content in its own error boundary that renders the fallback inside the portal container. This keeps the UI consistent. Also, note that error boundaries do not catch errors in event handlers, async code, or during server-side rendering. For portals, the most common error is a missing container node, which throws synchronously during render and is caught by error boundaries.
import React from 'react'; import ReactDOM from 'react-dom'; class PortalErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } render() { if (this.state.hasError) { return ReactDOM.createPortal( <div>Something went wrong in the portal.</div>, document.getElementById('portal-root') ); } return this.props.children; } } export default PortalErrorBoundary;
Testing Components with Portals
Testing portal components requires some setup because the portal container may not exist in the test environment. In Jest with React Testing Library, you can manually add the portal container to the document body before each test and clean it up after. Alternatively, you can mock ReactDOM.createPortal to render children in place. The latter is simpler but doesn't test the actual portal behavior. For integration tests, it's better to use the real portal. Also, be aware that getByRole queries might find elements inside the portal because they are in the DOM, but within scoping works as expected. For focus trapping tests, you need to simulate Tab key events and check document.activeElement. Testing library's fireEvent.keyDown works. Remember to clean up the portal container to avoid test pollution.
import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import Modal from './Modal'; beforeEach(() => { const portalRoot = document.createElement('div'); portalRoot.setAttribute('id', 'portal-root'); document.body.appendChild(portalRoot); }); afterEach(() => { const portalRoot = document.getElementById('portal-root'); if (portalRoot) document.body.removeChild(portalRoot); }); test('modal renders and closes on Escape', () => { const onClose = jest.fn(); render(<Modal isOpen={true} onClose={onClose}><p>Modal content</p></Modal>); expect(screen.getByText('Modal content')).toBeInTheDocument(); fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).toHaveBeenCalled(); });
Performance Considerations
Portals themselves are lightweight, but they can cause performance issues if used incorrectly. Each portal creates a separate React root? No, it's still part of the same root, but the DOM update might be more expensive because React needs to reconcile across different parts of the DOM tree. In practice, the overhead is negligible. However, if you have many portals (e.g., hundreds of tooltips), each with its own state, you might see slowdowns. Consider using a single portal container and managing multiple tooltips via absolute positioning within that container. Also, avoid unnecessary re-renders: if a portal's parent re-renders, the portal content re-renders too. Use React.memo or useMemo for expensive portal children. For animations, CSS transitions are fine, but if you use JS animations, ensure they are performant. Finally, be mindful of memory: if you create portal containers dynamically, clean them up.
import React, { useState, useCallback } from 'react'; import ReactDOM from 'react-dom'; const Tooltip = React.memo(({ text, position }) => { return ReactDOM.createPortal( <div style={{ position: 'fixed', top: position.top, left: position.left, background: '#333', color: '#fff', padding: 4 }}> {text} </div>, document.getElementById('portal-root') ); }); const TooltipList = ({ items }) => { const [positions, setPositions] = useState({}); // ... logic to compute positions return ( <> {items.map(item => ( <Tooltip key={item.id} text={item.text} position={positions[item.id]} /> ))} </> ); }; export default TooltipList;
Common Pitfalls and How to Avoid Them
- Missing container: The most common error. Always ensure the portal container exists. Use a fallback or create it dynamically. 2. Z-index wars: Portals don't automatically solve z-index issues if multiple portals stack. Use a stacking context manager or a single portal container with controlled z-index. 3. Accessibility: Screen readers navigate the DOM tree. A modal far from its trigger can be confusing. Use
aria-ownsoraria-describedbyto associate them. 4. Event handling: Native events don't bubble through the React tree. If you useaddEventListenerondocument, you might get unexpected behavior. 5. Server-side rendering: Portals require a DOM container, which doesn't exist on the server. Always checktypeof window !== 'undefined'or use a dynamic import. 6. Memory leaks: If you create portal containers dynamically, remove them on unmount. Also, clean up event listeners inuseEffectreturn.
import React from 'react'; import ReactDOM from 'react-dom'; const SSRSafePortal = ({ children }) => { const [mounted, setMounted] = React.useState(false); React.useEffect(() => { setMounted(true); }, []); if (!mounted || typeof window === 'undefined') return null; return ReactDOM.createPortal(children, document.getElementById('portal-root')); }; export default SSRSafePortal;
typeof window or using a mounted state.Native
The native HTML <dialog> element provides built-in modal behavior with a backdrop and focus management. Combining it with React Portals allows you to render the dialog outside the component tree while leveraging the native API. To use this pattern, create a portal root in your HTML (e.g., <div id="dialog-root"></div>). Then, in your React component, render a <dialog> element inside a portal. Use the showModal() method to open it and to close it. The native close()<dialog> handles focus trapping and escape key closing automatically. However, you must still manage state (open/closed) in React. Here's an example:
```jsx import { createPortal } from 'react-dom'; import { useRef } from 'react';
function NativeDialog({ open, onClose, children }) { const dialogRef = useRef(null);
useEffect(() => { const dialog = dialogRef.current; if (open) { dialog.showModal(); } else { dialog.close(); } }, [open]);
return createPortal( <dialog ref={dialogRef} onClose={onClose}> {children} <button onClick={onClose}>Close</button> </dialog>, document.getElementById('dialog-root') ); } ```
This pattern reduces boilerplate compared to custom modal implementations. Note that the <dialog> element's backdrop is not customizable via CSS in all browsers, so you may need to style the ::backdrop pseudo-element. Also, ensure you handle the onClose event to sync React state. This approach is ideal for simple modals where native behavior suffices.
import { createPortal } from 'react-dom'; import { useRef, useEffect } from 'react'; function NativeDialog({ open, onClose, children }) { const dialogRef = useRef(null); useEffect(() => { const dialog = dialogRef.current; if (open) { dialog.showModal(); } else { dialog.close(); } }, [open]); return createPortal( <dialog ref={dialogRef} onClose={onClose}> {children} <button onClick={onClose}>Close</button> </dialog>, document.getElementById('dialog-root') ); } export default NativeDialog;
<dialog> element simplifies modal implementation by leveraging built-in focus management and backdrop behavior.Portal Stack for Nested Modals
When building complex UIs, you may need to open a modal on top of another modal (nested modals). A portal stack pattern manages multiple portal instances, ensuring proper z-index and focus management. The idea is to maintain a stack of open modals, each rendered in its own portal, with the topmost modal receiving focus. To implement this, create a context that holds the modal stack and provides methods to push/pop modals. Each modal component registers itself on mount and unregisters on unmount. The top modal can be highlighted by applying a higher z-index. Here's a simplified example:
```jsx import { createContext, useContext, useState, useCallback } from 'react'; import { createPortal } from 'react-dom';
const ModalStackContext = createContext();
export function ModalStackProvider({ children }) { const [stack, setStack] = useState([]);
const pushModal = useCallback((id) => { setStack(prev => [...prev, id]); }, []);
const popModal = useCallback(() => { setStack(prev => prev.slice(0, -1)); }, []);
return ( <ModalStackContext.Provider value={{ stack, pushModal, popModal }}> {children} </ModalStackContext.Provider> ); }
function Modal({ id, children }) { const { stack, pushModal, popModal } = useContext(ModalStackContext); const isTop = stack[stack.length - 1] === id;
useEffect(() => { pushModal(id); return () => popModal(); }, [id]);
return createPortal( <div style={{ zIndex: isTop ? 1000 : 999 }}> {children} </div>, document.getElementById('modal-root') ); } ```
This pattern ensures that only the top modal is interactive. For accessibility, manage focus by moving it to the top modal when it opens. Also, consider using a library like react-focus-lock to trap focus within the active modal.
import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; const ModalStackContext = createContext(); export function ModalStackProvider({ children }) { const [stack, setStack] = useState([]); const pushModal = useCallback((id) => { setStack(prev => [...prev, id]); }, []); const popModal = useCallback(() => { setStack(prev => prev.slice(0, -1)); }, []); return ( <ModalStackContext.Provider value={{ stack, pushModal, popModal }}> {children} </ModalStackContext.Provider> ); } function Modal({ id, children }) { const { stack, pushModal, popModal } = useContext(ModalStackContext); const isTop = stack[stack.length - 1] === id; useEffect(() => { pushModal(id); return () => popModal(); }, [id, pushModal, popModal]); return createPortal( <div style={{ zIndex: isTop ? 1000 : 999 }}> {children} </div>, document.getElementById('modal-root') ); } export default Modal;
Animating Portals with Framer Motion
Animating portal content can be tricky because the portal is outside the parent DOM hierarchy. Framer Motion's AnimatePresence component works seamlessly with portals when used correctly. The key is to wrap the portal content in a motion component and use AnimatePresence to handle enter/exit animations. Since the portal is rendered in a different part of the DOM, you must ensure that AnimatePresence is placed inside the portal root or that the portal content is a direct child of AnimatePresence. The recommended approach is to render AnimatePresence inside the portal container. Here's an example:
```jsx import { motion, AnimatePresence } from 'framer-motion'; import { createPortal } from 'react-dom';
function AnimatedModal({ open, onClose, children }) { return createPortal( <AnimatePresence> {open && ( <motion.div key="modal" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.2 }} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)' }} > <div style={{ background: 'white', padding: 20, borderRadius: 8 }}> {children} <button onClick={onClose}>Close</button> </div> </motion.div> )} </AnimatePresence>, document.getElementById('modal-root') ); } ```
This pattern works because AnimatePresence is inside the portal, so it can track the mount/unmount of the motion component. For more complex animations (e.g., staggered children), you can nest motion components. Remember to set the key prop on the motion element to help Framer Motion track the animation state. Also, consider using layoutId for shared layout animations between portals and their triggers.
import { motion, AnimatePresence } from 'framer-motion'; import { createPortal } from 'react-dom'; function AnimatedModal({ open, onClose, children }) { return createPortal( <AnimatePresence> {open && ( <motion.div key="modal" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.2 }} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)' }} > <div style={{ background: 'white', padding: 20, borderRadius: 8 }}> {children} <button onClick={onClose}>Close</button> </div> </motion.div> )} </AnimatePresence>, document.getElementById('modal-root') ); } export default AnimatedModal;
layoutId to animate between a trigger element and the portal modal for a seamless transition. Also, test animations on low-end devices to ensure performance.| File | Command / Code | Purpose |
|---|---|---|
| PortalExample.jsx | const Portal = ({ children }) => { | What Are React Portals? |
| EventBubbling.jsx | const Parent = () => { | Event Bubbling Through Portals |
| DynamicPortalRoot.jsx | const usePortal = () => { | Setting Up a Portal Root |
| Modal.jsx | const Modal = ({ isOpen, onClose, children }) => { | Building a Production Modal with Portal |
| Tooltip.jsx | const Tooltip = ({ text, children }) => { | Portals for Tooltips and Dropdowns |
| ContextPortal.jsx | const ThemeContext = React.createContext('light'); | Portals and Context API |
| ErrorBoundaryPortal.jsx | class PortalErrorBoundary extends React.Component { | Error Boundaries and Portals |
| PortalTest.test.jsx | beforeEach(() => { | Testing Components with Portals |
| OptimizedTooltipList.jsx | const Tooltip = React.memo(({ text, position }) => { | Performance Considerations |
| SSRSafePortal.jsx | const SSRSafePortal = ({ children }) => { | Common Pitfalls and How to Avoid Them |
| NativeDialog.jsx | function NativeDialog({ open, onClose, children }) { | Native |
| ModalStack.jsx | const ModalStackContext = createContext(); | Portal Stack for Nested Modals |
| AnimatedModal.jsx | function AnimatedModal({ open, onClose, children }) { | Animating Portals with Framer Motion |
Key takeaways
Common mistakes to avoid
3 patternsNot understanding React component re-rendering
Ignoring the rules of hooks
Mutating state directly instead of using setState
Interview Questions on This Topic
What is the Virtual DOM and how does React use it?
Explain the difference between state and props.
What is the purpose of the useEffect hook?
How does React handle keys in lists?
Frequently Asked Questions
A React Portal is a way to render a component's children into a different part of the DOM tree, outside the parent component's DOM hierarchy, while keeping the React component tree intact. It's created with ReactDOM.createPortal(child, container).
Yes, React events bubble according to the React component tree, not the DOM tree. So an event fired inside a portal will bubble to parent React components. Native DOM events follow the DOM tree, so they won't bubble across portal boundaries.
Use a ref on the modal content container. On mount, focus the first focusable element. On Tab key, cycle focus between the first and last focusable elements. On close, restore focus to the previously focused element. This ensures keyboard accessibility.
No, portals require a DOM container, which doesn't exist on the server. You must guard against SSR by checking typeof window !== 'undefined' or using a state that only sets to true after mount. Return null during SSR.
In Jest, add the portal container to the document body in beforeEach and remove it in afterEach. Alternatively, mock ReactDOM.createPortal to return children directly for unit tests. For integration tests, use the real portal.
Portals themselves are lightweight, but having many (e.g., hundreds) can cause performance issues due to re-renders. Use React.memo on portal children, batch state updates, and consider using a single portal container with absolute positioning for multiple elements.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React. Mark it forged?
9 min read · try the examples if you haven't