React Portals: Modals, Tooltips and Dropdowns That Break Out of DOM
React portals let modals, tooltips and dropdowns escape overflow:hidden and z-index traps. Here's how to build them correctly without the usual gotchas.
What React Portals Actually Are (and Why You Need Them)
You've built a modal. It looks perfect in isolation. Then you nest it inside a sidebar that has overflow: hidden on it and suddenly your beautifully crafted overlay is getting clipped at the sidebar boundary. Sound familiar? That's the problem portals solve.
React portals, introduced in React 16.0 back in 2017, let you render a component's output into a different DOM node than its parent — while keeping it inside the React component tree for all the things that matter: event bubbling, context, state. The DOM location changes. The React tree location doesn't.
The API is dead simple: ReactDOM.createPortal(children, domNode). Two arguments. You pass what you want to render and where in the DOM you want it to land. Most of the time, that target is document.body, but it doesn't have to be.
Worth noting: portals aren't a React 18 thing, a Server Components thing, or a Next.js-specific thing. They're a core, stable React API that works everywhere. You're probably underusing them.
The Three CSS Problems Portals Solve
Before writing any code, let's be precise about why portals exist. There are three specific CSS situations that break floating UI when it's rendered inside a deep component tree.
First, overflow: hidden or overflow: clip on an ancestor. This is the most common one. Your modal parent has a scrollable container, and anything that tries to visually escape that container gets cut off — even with position: absolute. Rendering into document.body sidesteps this entirely.
Second, stacking context problems. Every time you write transform, filter, isolation: isolate, will-change, or opacity < 1 on an element, you create a new stacking context. Your z-index: 9999 on the modal means nothing inside that stacking context if the context itself has a lower index than a competing element. Portaling out to body puts your modal in the root stacking context where z-index actually behaves predictably.
Third — and this one is subtle — position: fixed stops being relative to the viewport when it has an ancestor with a CSS transform. Fixed elements are supposed to position against the viewport, but that breaks the moment you put them inside a transformed container. Again, portal to body and it's gone.
In practice, you'll hit at least one of these in every non-trivial UI. Tooltips and dropdowns positioned relative to a button inside a table inside a card inside a modal? Yeah, you need portals there.
Building a Reusable Portal Component
Let's write one. You don't need a library for basic portal behavior — 20 lines covers you for most cases.
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface PortalProps {
children: React.ReactNode;
containerId?: string;
}
export function Portal({ children, containerId = 'portal-root' }: PortalProps) {
const containerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
containerRef.current = container;
// force a re-render now that container exists
}, [containerId]);
if (!containerRef.current) return null;
return createPortal(children, containerRef.current);
}The containerId param matters more than you'd think. If you throw everything into one #portal-root, you can't independently control stacking order between modals and tooltips. Give tooltips their own container that lives above the modal container in DOM order — stacking context and z-index become way more predictable.
Quick aside: in Next.js App Router (Next 13+), you need to mark any component using useEffect or createPortal with 'use client'. SSR doesn't have a DOM, so your portal component needs to be client-side. The if (!containerRef.current) return null guard handles the server render case too — it returns nothing on the server, then hydrates correctly on the client.
One more thing — the container div you're appending to body should get aria-live or role attributes as appropriate for accessibility. A modal portal should have role="dialog" and aria-modal="true" on the actual dialog element, not the portal container itself.
Modal Pattern: Backdrop, Focus Trap, Scroll Lock
A modal isn't just createPortal(<div>content</div>, document.body). Done right, it needs three things: a click-outside/escape handler, scroll lock on body, and focus trap so keyboard users can't tab behind the overlay.
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
export function Modal({ open, onClose, children }: {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
// Scroll lock
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
// Escape key
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
useEffect(() => {
if (!open) return;
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, handleKeyDown]);
if (!open) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<div className="relative z-10 rounded-xl bg-white p-8 shadow-2xl max-w-lg w-full mx-4">
{children}
</div>
</div>,
document.body
);
}The backdrop-blur-sm on the overlay gives you that glassmorphism vibe without pulling in a whole design system. It's 12px of blur defaulting to Tailwind's config. You can push it harder or swap to a solid dark background depending on your design direction.
Honestly, focus trapping is the part most people skip and it's the one that'll get you in an accessibility audit. The focus-trap-react package is 3kB and handles it without you having to manually query all focusable elements. Use it. The scroll lock implementation above covers document.body but if your app wraps everything in a custom scroll container, you'll need to target that element instead.
For z-index, I'd recommend committing to a scale in your design tokens: tooltips at 40, dropdowns at 30, modals at 50, toasts at 60. Having those numbers documented somewhere saves a lot of z-index: 99999 commits.
Tooltip and Dropdown Positioning with portals
Tooltips and dropdowns are different from modals — they need to track a trigger element's position. You can't just throw them at position: fixed; top: 0; left: 0 and call it done.
The pattern is: on open, call getBoundingClientRect() on the trigger element, get the coordinates, then position the portal'd element using those coordinates with position: fixed. Fixed positioning on a portal'd element inside body is always relative to the viewport, which is exactly what you want.
import { useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
export function Tooltip({ label, children }: { label: string; children: React.ReactNode }) {
const triggerRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
const show = useCallback(() => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
setCoords({
top: rect.top - 40, // 40px above trigger
left: rect.left + rect.width / 2,
});
}, []);
const hide = useCallback(() => setCoords(null), []);
return (
<>
<div ref={triggerRef} onMouseEnter={show} onMouseLeave={hide}>
{children}
</div>
{coords && createPortal(
<div
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
transform: 'translateX(-50%)',
pointerEvents: 'none',
}}
className="z-40 rounded bg-gray-900 px-2 py-1 text-xs text-white whitespace-nowrap"
>
{label}
</div>,
document.body
)}
</>
);
}That -40px offset is just a starting point. For real tooltip positioning you'd want to detect viewport edges and flip the tooltip above/below/left/right depending on available space. Floating UI (formerly Popper.js v2) handles all of this and it's designed to work alongside portals — it computes the position, you render wherever you want.
That said, if you want styled tooltip components that are already wired up and look good, browse components — the Empire UI library includes pre-built overlay components across multiple design styles so you're not reinventing this every project.
Event Bubbling Through Portals (It's Surprising)
Here's the thing that trips people up: React events bubble through the React tree, not the DOM tree. That means if you portal a modal into document.body and click something inside it, that click event bubbles up through React to the modal's React parent — even though in the DOM, the modal is nowhere near that parent.
This is usually what you want. It means context providers work, React state updates propagate correctly, and onClick handlers on ancestors fire as expected. But it can bite you with stopPropagation.
// This is a common trap:
function Parent() {
return (
<div onClick={() => console.log('parent clicked')}>
<Modal open>
{/* This click WILL bubble to Parent's onClick in React */}
<button onClick={(e) => e.stopPropagation()}>Click me</button>
</Modal>
</div>
);
}Look, if you're building a "click outside to close" handler by listening at the document level, portal event bubbling means clicks inside the modal will reach the document handler and potentially close the modal they're inside. The fix is checking event.target — if the portal's container contains the target, it's an inside click. The modal pattern above using a separate backdrop onClick avoids this entirely.
Worth noting: this bubbling behavior is React's synthetic event system, not native DOM events. If you add a native window.addEventListener('click', ...), it sees the DOM tree and your portal'd content is correctly attached to body — no cross-tree bubbling there. The distinction matters when you're mixing native and synthetic event handlers.
Portals in Next.js App Router and SSR
Server-side rendering makes portals slightly more involved. document doesn't exist on the server, so any code that touches it needs to either be in a useEffect (which only runs client-side) or be inside a 'use client' component that Next.js won't attempt to render on the server.
The cleanest pattern for Next.js 14+ is a dedicated ClientPortal component that's explicitly marked as client-only and handles the mount check:
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
export function ClientPortal({ children, selector = '#portal-root' }: {
children: React.ReactNode;
selector?: string;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const target = document.querySelector(selector) ?? document.body;
return createPortal(children, target);
}The mounted state flag is the standard SSR hydration guard. Without it, the server renders null (fine), then the client hydrates and immediately tries to attach to a DOM node that may not have existed during the server render — causing a hydration mismatch. Setting mounted in useEffect guarantees this code only runs after the client has fully hydrated.
For the #portal-root target, add a <div id="portal-root" /> to your root layout — in Next.js that's app/layout.tsx. Put it right before the closing </body> tag. Or skip the custom container and fall back to document.body like the example above does. Either works; the custom container just gives you more control over stacking.
Honestly, once you've set this pattern up in a project you basically never think about it again. The ClientPortal wrapper becomes a boring utility component. That's a good sign. If you're building design-system-grade components and want a reference for how overlay-heavy styles handle this — the cyberpunk and vaporwave component sets in Empire UI both use portal-based overlays with SSR-safe mounting.
FAQ
Yes. Portals stay in the React tree even though the DOM node moves, so context providers and Redux store access work exactly as if the portal'd component were rendered in-place. No wrappers needed.
With vanilla JS you lose React's event system, state management, and context entirely — it's a detached component. Portals give you full React functionality in the target DOM node. It's not comparable.
Absolutely. Wrap your portal'd content in a Framer Motion AnimatePresence and motion.div as you normally would. The animation system doesn't care about the DOM location, only the React tree.
You probably have a click handler on document or a parent element that fires on every click. React events bubble through the React tree, so clicks inside your portal reach parent handlers. Check your event propagation logic and make sure you're not accidentally closing on internal clicks.