React Portals: Modals, Tooltips, and Drawers Done Correctly
React portals let you escape the DOM tree for modals, tooltips, and drawers without z-index nightmares. Here's exactly how to build them properly.
Why React Portals Exist (And Why You Actually Need Them)
Honestly, half the z-index bugs you've fought in your career didn't need to exist. They were a side effect of rendering floating UI — modals, tooltips, dropdowns — inside a parent element with overflow: hidden or position: relative. React portals fix that at the architectural level.
ReactDOM.createPortal lets you render a child component into a different DOM node than its parent. The component still lives inside your React tree (events bubble normally, context works fine), but the actual DOM output goes somewhere else — usually a <div id="portal-root"> at the top of <body>.
This sounds simple. It is. But there's still a right and wrong way to do it, especially once you add focus trapping, scroll locking, keyboard navigation, and animation. That's what this article is about.
Setting Up a Portal Root in Next.js and Vite Apps
Before writing a single portal component, you need a mount point in your HTML. In Next.js App Router, add it to your root layout.tsx. In Vite React apps, drop it in index.html. Either way, it's one line.
// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<div id="portal-root" />
</body>
</html>
);
}That <div id="portal-root"> sits outside your main #__next or #root container. No stacking context from your app layout can reach it. Z-index 50, 9999, whatever you want — it'll be on top.
Building a Reusable Portal Component in TypeScript
The raw ReactDOM.createPortal API is fine, but wrapping it in a component gives you a cleaner interface and handles the SSR edge case (the portal root doesn't exist server-side, which crashes Next.js if you're not careful).
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface PortalProps {
children: React.ReactNode;
containerId?: string;
}
export function Portal({ children, containerId = 'portal-root' }: PortalProps) {
const [mounted, setMounted] = useState(false);
const containerRef = useRef<Element | null>(null);
useEffect(() => {
containerRef.current = document.getElementById(containerId);
setMounted(true);
return () => { setMounted(false); };
}, [containerId]);
if (!mounted || !containerRef.current) return null;
return createPortal(children, containerRef.current);
}The mounted state guard means we skip rendering on the server pass entirely. Once the component mounts on the client, containerRef.current points to the real DOM node and React renders into it. You can also pass a custom containerId if you need separate portal roots for different z-index layers — useful when you have both a modal layer and a tooltip layer.
Modal Implementation: Overlay, Focus Trap, and Scroll Lock
A modal that doesn't trap focus is an accessibility failure. Screen reader users and keyboard-only users will tab straight past your modal backdrop into the content underneath. That's not acceptable.
Focus trapping means intercepting Tab and Shift+Tab key events and cycling through focusable elements within the modal container. You can write this yourself or use focus-trap-react (32kb unpacked). For scroll lock, set document.body.style.overflow = 'hidden' on open and restore it on close — but remember to handle nested modals so the second close doesn't accidentally unlock scroll while the first modal is still open.
import { useEffect, useRef } from 'react';
import FocusTrap from 'focus-trap-react';
import { Portal } from './Portal';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, children }: ModalProps) {
useEffect(() => {
if (!isOpen) return;
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = original; };
}, [isOpen]);
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}, [onClose]);
if (!isOpen) return null;
return (
<Portal>
<FocusTrap>
<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 max-w-lg w-full mx-4 rounded-xl bg-white dark:bg-zinc-900 p-6 shadow-2xl">
{children}
</div>
</div>
</FocusTrap>
</Portal>
);
}Notice role="dialog" and aria-modal="true" on the container. These are required for screen readers to understand what's happening. Also notice the backdrop gets onClick={onClose} but the content div doesn't propagate that click — no need for e.stopPropagation() because the content sits on top of the backdrop in the stacking order.
Tooltip Portals: Positioning Without Going Crazy
Tooltips are trickier than modals because they need to position themselves relative to a trigger element — but they're rendered in a completely different part of the DOM. The trigger's bounding rect is your anchor.
Use getBoundingClientRect() on the trigger ref, then calculate the tooltip's position based on window.scrollX and window.scrollY. A tooltip that appears 8px above its trigger with a 4px horizontal offset for centering looks like this in practice: top: rect.top + scrollY - tooltipHeight - 8 and left: rect.left + scrollX + rect.width / 2 - tooltipWidth / 2. You'll want to recalculate on scroll and resize with a ResizeObserver or a debounced scroll handler.
What about overflow detection? Your tooltip might render off the right edge of the viewport. Check left + tooltipWidth > window.innerWidth and flip to a different placement. This is exactly what Floating UI (formerly Popper.js) does — and honestly, for anything beyond basic tooltips it's worth using it. It handles all the edge cases you don't want to rediscover yourself. Pair it with your portal wrapper and you get a clean, composable tooltip system.
If you're also exploring animation for these overlays, take a look at how particles backgrounds handle rendering outside the main React tree — it's a related pattern worth understanding.
Drawer Components: Animation and the Hidden State Problem
Drawers (side panels that slide in from the left or right) have a unique challenge: they need to animate in AND out, but you can't just unmount the component when isOpen turns false — the exit animation never plays.
The pattern is to keep the component mounted but control visibility through CSS transitions. Use a combination of translate-x-full and translate-x-0 driven by the isOpen prop, wrapped in a transition-transform duration-300 ease-in-out class. The backdrop opacity transitions separately with transition-opacity.
For a truly polished drawer, you also want to delay the Portal unmount until after the animation completes. A simple approach: track an isVisible state that stays true for 300ms after isOpen goes false using setTimeout. A cleaner approach: listen to the transitionend event on the drawer element and unmount after it fires. This pairs well with theme toggle patterns in React where you're similarly managing state that affects multiple visual layers simultaneously.
Don't forget the drawer also needs focus trap and scroll lock — same as the modal. The drawer is just a modal with a different visual metaphor.
Event Bubbling Through Portals: The Surprise That Gets Everyone
Here's something that trips up most developers the first time they use portals: even though your portal renders outside the parent DOM node, React events still bubble through the React component tree, not the DOM tree. This is intentional.
What does that mean in practice? If you have an onClick handler on a parent component, and your portal triggers a click event, that event will bubble up to the parent in React's synthetic event system. This can cause unexpected behavior if you're not aware of it. Why does React do this? Because it preserves the logical parent-child relationship for event handling, which is usually what you want.
The practical fix: if you need to stop bubbling, use e.stopPropagation() on the portal's root element. But think carefully first — most of the time the bubbling behavior is what you actually want. This is also why context works through portals. Your theme context, auth context, whatever — all of it flows down through the React tree regardless of where the DOM ends up. The React performance guide has more detail on how React's reconciliation treats portal subtrees.
Testing Portals with React Testing Library
Testing portal components used to be painful. The portal root doesn't exist in jsdom by default, so your tests would fail with a null container error. The fix is straightforward: add the portal root to document.body in your test setup.
// In your test file or setup
beforeEach(() => {
const portalRoot = document.createElement('div');
portalRoot.setAttribute('id', 'portal-root');
document.body.appendChild(portalRoot);
});
afterEach(() => {
document.getElementById('portal-root')?.remove();
});
// Your test
import { render, screen, fireEvent } from '@testing-library/react';
import { Modal } from './Modal';
test('modal closes on Escape key', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose}><p>Content</p></Modal>);
// Content renders into portal, RTL still queries across the whole document
expect(screen.getByText('Content')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});React Testing Library queries the entire document by default, so screen.getByText('Content') will find elements inside the portal without any special setup. The portal root cleanup in afterEach prevents leakage between tests. If you're integrating portals with a broader component system — say, with React toast notifications that also use portals — you might want a shared test setup that adds multiple portal roots at once.
FAQ
Yes, portals work correctly with concurrent rendering, Suspense boundaries, and transitions in React 18. The portal content participates in the same render batching and concurrent scheduling as the rest of your tree. One thing to watch: if you use startTransition to control modal open state, the portal content will render in the transition pass, which might cause a brief flash if you're not careful with your loading states.
Keep a counter or stack in a context/store. Each modal increments the counter on mount and decrements on unmount. Use the counter to calculate z-index (base z-index + counter * 10, for example). For scroll lock, only the first modal should set overflow:hidden and only the last one to close should restore it — which is exactly why checking the counter before restoring is essential.
You're recalculating position synchronously on scroll events, which runs after paint and causes a one-frame delay. Use useLayoutEffect instead of useEffect for position calculations, or better yet, use a requestAnimationFrame wrapper inside your scroll handler. Floating UI handles this internally, which is one reason to reach for it over a custom implementation.
Yes. Context flows through the React component tree, not the DOM tree. Your portal content is still a descendant in the React tree, so all context values — theme, auth, i18n, whatever — are fully accessible inside the portal. This is one of the nicest properties of React portals.
Use a CSS class toggle pattern. Keep the portal mounted but toggle a CSS class that triggers a transform and opacity transition (e.g., Tailwind's transition-all duration-200 ease-out). For exit animations, delay unmounting by listening to the transitionend DOM event on the portal's root element, or use a fixed timeout that matches your CSS transition duration.
One portal root is fine for most apps. Use multiple roots if you need strict z-index layering between different overlay types — for example, tooltips always above drawers, drawers always above modals. In that case, create three separate div elements (modal-root, drawer-root, tooltip-root) and assign each a different z-index range. Each Portal component instance targets the appropriate root via the containerId prop.