React Portal
What is a React Portal?
A React Portal is a built-in React feature that lets you render a child component
into a different DOM node than its parent component, while still keeping it inside the same
React component tree (and inheriting context, state, and event bubbling from its logical parent).
In simple words: React normally renders everything inside a single root DOM element (usually
<div id="root"></div>). A Portal breaks out of this root and injects
the component’s output into any other DOM node on the page.
Official React Definition (docs.react.dev):
“Portals provide a first-class way to render children into a DOM node that exists outside the
DOM hierarchy of the parent component.”
React Portals were introduced in React 16.0 (September 2017) and remain
unchanged and fully supported in React 18+.
2. Why Do We Need Portals?
React’s default rendering mounts all components inside one root div. This works perfectly for
most UIs. But some UI elements, like modals, tooltips, dropdowns, and drawers, need to live
outside the normal document flow for visual and behavioural reasons:
- z-index stacking problems: If a modal is nested deep in the DOM, a parent
element withoverflow: hiddenor a lowerz-indexcan clip or hide it. - CSS overflow clipping: Tooltips and dropdowns can be cut off by
overflow: hiddenancestors. - Visual independence: Notifications and overlays must appear above all other
content regardless of where the triggering component sits in the tree. - Positioning relative to viewport: Fixed-position overlays must be free from
CSS transform or filter ancestors that breakposition: fixed.
Portals solve all of these problems by injecting the rendered output into a DOM node at the top
level (usually directly inside <body>), while React still treats the component
as part of its logical tree.
3. The Problem Portals Solve
Imagine this component tree:
<App>
<Sidebar> <-- overflow: hidden; position: relative
<UserCard>
<Modal /> <-- supposed to fill the entire screen
</UserCard>
</Sidebar>
</App>
Without Portals, the Modal is trapped inside the Sidebar DOM node. Its CSS will be restricted
by the parent’s overflow: hidden and lower stacking context. The modal appears
clipped or behind other elements.
With a Portal, the Modal is rendered like this in the real DOM:
<body>
<div id="root">
<!-- App, Sidebar, UserCard are here -->
</div>
<div id="modal-root">
<!-- Modal rendered here — free from parent constraints -->
</div>
</body>
But React still treats the Modal as a child of UserCard for event bubbling, context, and state
management. Best of both worlds.
4. How React Portals Work Internally
When you use a Portal, React maintains two separate trees:
- React Component Tree (Logical/Virtual): The Portal’s component lives here as
a normal child. Events, Context, and props flow through this tree as usual. - Real DOM Tree (Physical): React mounts the Portal’s output into a different
DOM node. This is only about where the HTML markup lives — not about React’s internal
reconciliation.
React’s reconciler handles both: it knows that some components are portals and directs their
rendered output to the target DOM node, while still keeping their place in the fiber tree
(React’s internal representation of the component tree).
Key facts about Portal internals:
- Portals participate in React’s lifecycle normally (componentDidMount, useEffect, etc.).
- They trigger re-renders when parent state changes, just like regular children.
- Unmounting the parent unmounts the portal’s output from the target DOM node.
- React uses the target container as a host for the rendered subtree; it does not clone or copy, it mounts directly.
Accessibility in Portals
Moving content to a different DOM node creates accessibility challenges because screen readers
follow the DOM structure, not React’s component tree. Here are the mandatory rules:
Required ARIA Attributes
| Attribute | Element | Purpose |
|---|---|---|
role="dialog" |
Modal container | Tells screen readers this is a dialog window |
aria-modal="true" |
Modal container | Prevents screen readers from browsing content outside the modal |
aria-labelledby |
Modal container | Points to the modal’s heading element ID |
aria-describedby |
Modal container | Points to an element describing the modal’s purpose |
role="tooltip" |
Tooltip container | Identifies tooltip content to screen readers |
aria-live="polite" |
Toast container | Announces new toasts without interrupting current reading |
aria-live="assertive" |
Error toast container | Interrupts immediately for critical error announcements |
Accessibility Checklist for Portals
- Move focus into the portal on open; return it to the trigger on close.
- Trap Tab/Shift+Tab within dialogs.
- Support Escape key to close all overlay portals.
- Use
aria-modal="true"on dialogs to restrict screen reader browsing. - Use
aria-liveon notifications. - Ensure sufficient colour contrast inside the portal’s overlay content.
- Do not hide important page content with
aria-hiddenwhen it is still accessible.
Common Mistakes
Mistake 1: Importing from wrong package
// WRONG
import { createPortal } from 'react';
// CORRECT
import { createPortal } from 'react-dom';
Mistake 2: Target element doesn’t exist
// WRONG – if #modal-root is not in HTML, this returns null and throws
createPortal(<Modal />, document.getElementById('modal-root'))
// CORRECT – check before using
const el = document.getElementById('modal-root');
if (el) return createPortal(<Modal />, el);
return null;
Mistake 3: Creating the DOM node on every render
// WRONG – creates a new div on every render, attaches to body repeatedly
function MyPortal({ children }) {
const el = document.createElement('div'); // new node every render!
document.body.appendChild(el);
return createPortal(children, el);
}
// CORRECT – use useMemo or module-level variable
const portalRoot = document.createElement('div');
document.body.appendChild(portalRoot);
function MyPortal({ children }) {
return createPortal(children, portalRoot);
}
Mistake 4: Forgetting to handle SSR
// WRONG in Next.js SSR – document is not defined on server
const container = document.getElementById('modal-root'); // ReferenceError
// CORRECT – guard with typeof check or useEffect
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
Mistake 5: Skipping cleanup in useEffect
// WRONG – event listener leaks on unmount
useEffect(() => {
document.addEventListener('keydown', handleKey);
// No cleanup!
}, []);
// CORRECT
useEffect(() => {
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, []);
Mistake 6: Assuming CSS inherits through portal
// CSS from the React parent does NOT apply to portal content
// because the portal content is in a different DOM location.
// You must apply styles directly to the portal's content or its DOM container.
Best Practices
- Declare portal containers in index.html: Keep them as siblings of
#root. Never nest them inside other elements. - Create one container per purpose: Use
#modal-root,
#toast-root,#tooltip-rootrather than dumping everything into
document.body. - Always handle SSR: Guard
documentaccess with
useEffector a mounted state flag. - Include full accessibility: ARIA roles, keyboard navigation,
focus management, these are not optional for overlays. - Clean up event listeners: Return cleanup functions from every
useEffectthat attaches todocument. - Conditionally render: Return
nullwhen the portal is
not needed rather than keeping it mounted empty. - Stable container reference: Use
useMemoor module-level
variable — don’t create a new DOM node on every render. - Combine Portals with Context: Expose addModal/addToast APIs through
Context so any component can trigger overlays without prop drilling. - Test with screen readers: VoiceOver, NVDA, or axe DevTools should
confirm that portal content is announced correctly. - Document z-index strategy: Define a project-wide z-index scale
(e.g. modal: 9999, toast: 99999, tooltip: 999999) to prevent stacking conflicts.
FAQs
Q1. Do Portals work with React’s Context API?
Yes, fully. Even though the portal’s DOM output is outside the parent’s DOM
node, the portal component still lives in the React component tree. It can read any Context
value provided by an ancestor in that tree.
Q2. Do events inside a Portal bubble up to its React parent?
Yes. Events bubble through the React component tree, not the DOM tree.
A click inside a portal will trigger onClick on the portal’s React parent
component, even though the DOM parent is different. Use e.stopPropagation() if
you want to prevent this.
Q3. Can I use multiple Portals in one application?
Yes. You can have as many portals as you like, each targeting different DOM
containers. Common setups have separate containers for modals, toasts, and tooltips.
Q4. Can a Portal render into a DOM node that is itself inside React’s root?
Technically yes, but it creates confusing and circular behaviour. The portal
target should always be a separate DOM node outside #root to achieve the desired
isolation.
Q5. Is ReactDOM.createPortal available in React Native?
No. createPortal is specific to react-dom and is
only available in web environments. React Native has its own patterns for overlays
(e.g. Modal component from react-native).
Q6. What happens to the portal when the parent component unmounts?
The portal is also unmounted. React handles cleanup automatically. The portal’s
DOM nodes are removed from the target container, and all useEffect cleanup
functions are called.
Q7. Can I use portals in React Server Components (RSC)?
No. Server Components do not support portals because they don’t have access to
the browser DOM. Mark your portal components with 'use client' directive in
Next.js App Router.
Q8. Can a Portal contain another Portal?
Yes. You can nest portals, a portal component can itself render another portal.
Each portal renders into its specified container independently.
Q9. Does using document.body as the portal target cause any problems?
Using document.body works and is common for tooltips and dropdowns. The risk is
that many portals rendering directly into body can make DOM inspection messy.
For modals and toasts, prefer dedicated named containers for clarity and easier CSS targeting.
Q10. How do I animate portal content (fade in / slide in)?
Animate portal content the same way you would animate any React component: with CSS transitions,
keyframes, or libraries like Framer Motion. Since the portal renders real DOM nodes, all CSS
animation techniques work. The animation should be applied to the content wrapper inside the
portal:
/* styles.css */
.modal-enter {
animation: fadeIn 200ms ease forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
{createPortal(
<div className="modal-enter">
{/* Modal content */}
</div>,
document.getElementById('modal-root')
)}
Q11. What is the key parameter in createPortal?
Added in React 18, the optional third argument key works like the key
prop on list items, it helps React identify and reconcile portals in a dynamic list.
{items.map(item =>
createPortal(
<Tooltip text={item.label} />,
document.body,
item.id // <-- key
)
)}
Q12. How are Portals different from rendering outside React entirely?
Rendering “outside React” (e.g. directly with document.createElement and
innerHTML) gives you a DOM node with no React integration — no state, no context,
no event system, no lifecycle. A Portal gives you a DOM node that lives outside the React root
but is still fully managed by React internally.
Q13. Can I use React.lazy with Portals?
Yes. You can lazy-load a component that uses or returns a portal just like
any other component:
const LazyModal = React.lazy(() => import('./Modal'));
<Suspense fallback={null}>
<LazyModal isOpen={open} onClose={close} />
</Suspense>
Q14. Is there a performance difference between a Portal and a regular component?
The difference is negligible. The only overhead is one extra DOM element (the container node),
plus the browser reflow for fixed/absolute positioning. React’s reconciliation for the portal
subtree is identical to that for a regular component subtree.
Q15. How do I test components that use Portals in Jest / React Testing Library?
React Testing Library handles portals automatically, the rendered output, including portal
content, is queryable in the same document. You may need to add the portal container node
to your test setup:
// setupTests.js (runs before tests)
beforeEach(() => {
const modalRoot = document.createElement('div');
modalRoot.id = 'modal-root';
document.body.appendChild(modalRoot);
});
afterEach(() => {
document.getElementById('modal-root')?.remove();
});
Summary
| Topic | Key Point |
|---|---|
| Import | import { createPortal } from 'react-dom' |
| Signature | createPortal(children, container, key?) |
| Introduced | React 16.0 (2017) |
| Physical DOM | Renders into the specified container node |
| Logical tree | Still part of the React component tree — context and events flow normally |
| Event bubbling | Bubbles through React tree, NOT the DOM tree |
| CSS inheritance | Follows the physical DOM, not the React tree |
| SSR | Not supported on the server — guard with useEffect + mounted state |
| React Native | Not available — web-only API |
| Server Components | Not supported — requires 'use client' |
| Unmount behaviour | Portal is unmounted when its parent component unmounts |
| Common uses | Modals, Tooltips, Dropdowns, Toasts, Drawers |
| Accessibility | Requires: role, aria-modal, focus management, keyboard handling |
| Performance | Minimal overhead; use stable container references and conditional rendering |
| Testing | RTL handles portals natively; add container node in test setup |
References
- React Official Documentation – Portals: https://react.dev/reference/react-dom/createPortal
- React Release Notes v16.0: https://legacy.reactjs.org/blog/2017/09/26/react-v16.0.html
- WAI-ARIA Dialog Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
- MDN – CSS Stacking Context: MDN Stacking Context
