React Portals and Advanced Patterns — Modals, Tooltips, Overlays
In this tutorial, you'll learn about React Portals and Advanced Patterns. We cover key concepts, practical examples, and best practices.
What You'll Learn
Use React portals to render children outside the parent DOM hierarchy — building accessible modals, tooltips, dropdowns, and notification systems with proper event bubbling, focus management, and TypeScript.
Why It Matters
Certain UI elements like modals, tooltips, and dropdowns need to break out of their parent's CSS context. Overflow hidden, z-index stacking, and parent transformations clip or hide these elements when rendered inside the component tree. Portals solve this by rendering to a different DOM node while preserving React context.
Real-World Use
A notification toast system where toasts render in a fixed container at the document root, a full-screen modal that needs to overlay all content regardless of where the trigger button sits in the DOM, and a tooltip that must not be clipped by an ancestor with overflow: hidden.
Learning Path
flowchart LR
A[React Basics] --> B[Custom Hooks]
B --> C[Portals and Patterns]
C --> D[Design Patterns]
D --> E[Error Boundaries]
E --> F[Final Project]
C -->|You are here| C
What is a Portal?
createPortal renders children into a different DOM node outside the parent component. The children still participate in React's event bubbling and context propagation.
import { createPortal } from "react-dom";
interface PortalProps {
children: React.ReactNode;
targetNode?: HTMLElement;
}
function Portal({ children, targetNode }: PortalProps) {
const target = targetNode || document.body;
return createPortal(children, target);
}
Building an Accessible Modal
A modal must: render at the document root, trap focus, close on Escape, close on backdrop click, and be announced by screen readers.
import { createPortal } from "react-dom";
import { useEffect, useRef, useCallback } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
// Trap focus within the modal
if (e.key === "Tab" && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusableElements[0];
const last = focusableElements[focusableElements.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) {
previousActiveElement.current = document.activeElement as HTMLElement;
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
// Focus the modal content
setTimeout(() => {
modalRef.current?.focus();
}, 0);
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
previousActiveElement.current?.focus();
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return createPortal(
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 9999,
}}
>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: "absolute",
inset: 0,
background: "rgba(0, 0, 0, 0.5)",
}}
aria-hidden="true"
/>
{/* Modal panel */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
style={{
position: "relative",
background: "#fff",
borderRadius: "8px",
padding: "24px",
maxWidth: "500px",
width: "90%",
maxHeight: "80vh",
overflow: "auto",
boxShadow: "0 20px 60px rgba(0, 0, 0, 0.3)",
}}
>
<header
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "16px",
}}
>
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
style={{
background: "none",
border: "none",
fontSize: "24px",
cursor: "pointer",
}}
>
x
</button>
</header>
{children}
</div>
</div>,
document.body
);
}
// Usage
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div style={{ overflow: "hidden" /* This would clip a non-portaled modal */ }}>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Confirm Action">
<p>Are you sure you want to proceed?</p>
<button onClick={() => setIsOpen(false)}>Confirm</button>
</Modal>
</div>
);
}
Expected output: Clicking "Open Modal" renders the modal at the document root, outside the parent div with overflow hidden. The backdrop is semi-transparent black. Pressing Escape closes it. Clicking the backdrop closes it. Tab cycles through focusable elements within the modal. Focus returns to the trigger button when closed.
Tooltip with Portal
Tooltips need to escape overflow containers and position relative to their trigger element.
import { createPortal } from "react-dom";
import { useState, useRef, useEffect } from "react";
interface TooltipProps {
content: string;
children: React.ReactNode;
}
type Position = { top: number; left: number };
function Tooltip({ content, children }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState<Position>({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
function show() {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
});
setVisible(true);
}
}
function hide() {
setVisible(false);
}
useEffect(() => {
function handleScroll() {
if (visible) hide();
}
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [visible]);
return (
<>
<span
ref={triggerRef}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
style={{ cursor: "pointer", textDecoration: "underline dotted" }}
tabIndex={0}
>
{children}
</span>
{visible && createPortal(
<div
ref={tooltipRef}
role="tooltip"
style={{
position: "fixed",
top: position.top,
left: position.left,
transform: "translateX(-50%)",
background: "#333",
color: "#fff",
padding: "4px 8px",
borderRadius: "4px",
fontSize: "12px",
whiteSpace: "nowrap",
zIndex: 10000,
pointerEvents: "none",
}}
>
{content}
</div>,
document.body
)}
</>
);
}
// Usage inside an overflow-hidden container
function OverflowContainer() {
return (
<div style={{ overflow: "hidden", height: 200, border: "1px solid #ccc" }}>
<p>
Hover over{" "}
<Tooltip content="This tooltip escapes the overflow container!">
this text
</Tooltip>{" "}
to see the tooltip.
</p>
</div>
);
}
Expected output: Hovering over "this text" shows a dark tooltip above the overflow-hidden boundary. The tooltip uses position: fixed relative to the viewport, so parent CSS cannot clip it. On scroll, the tooltip hides to avoid misalignment.
Toast Notification System
A portal-based toast system that stacks notifications in a fixed container.
import { createPortal } from "react-dom";
import { useState, useCallback, useEffect } from "react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
duration: number;
}
let toastIdCounter = 0;
let addToastExternal: ((message: string, type: ToastType, duration?: number) => void) | null = null;
function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: ToastType = "info", duration = 4000) => {
const id = `toast-${++toastIdCounter}`;
setToasts((prev) => [...prev, { id, message, type, duration }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
useEffect(() => {
addToastExternal = addToast;
return () => { addToastExternal = null; };
}, [addToast]);
useEffect(() => {
const timers = toasts.map((toast) =>
setTimeout(() => removeToast(toast.id), toast.duration)
);
return () => timers.forEach(clearTimeout);
}, [toasts, removeToast]);
const bgColor: Record<ToastType, string> = {
success: "#28a745",
error: "#dc3545",
info: "#007bff",
};
return createPortal(
<div
style={{
position: "fixed",
top: "16px",
right: "16px",
display: "flex",
flexDirection: "column",
gap: "8px",
zIndex: 10001,
}}
>
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
aria-live="polite"
style={{
background: bgColor[toast.type],
color: "#fff",
padding: "12px 16px",
borderRadius: "6px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
display: "flex",
alignItems: "center",
gap: "12px",
minWidth: "280px",
animation: "slideIn 0.3s ease",
}}
>
<span style={{ flex: 1 }}>{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
aria-label="Dismiss"
style={{
background: "none",
border: "none",
color: "#fff",
cursor: "pointer",
fontSize: "16px",
}}
>
x
</button>
</div>
))}
</div>,
document.body
);
}
// Imperative API for dispatching toasts from anywhere
function showToast(message: string, type: ToastType = "info") {
addToastExternal?.(message, type);
}
export { ToastContainer, showToast };
// Usage in any component
function SomeComponent() {
return (
<button onClick={() => showToast("Data saved successfully!", "success")}>
Save
</button>
);
}
Expected output: Clicking "Save" triggers a green toast at the top-right of the viewport. Multiple toasts stack vertically. Each auto-dismisses after 4 seconds. The dismiss button removes individual toasts. The showToast function works from anywhere without needing component context.
Event Bubbling Through Portals
Portals preserve React's event bubbling, even though the DOM nodes are in different parts of the tree.
import { createPortal } from "react-dom";
import { useState } from "react";
function PortalEventDemo() {
const [clicks, setClicks] = useState<string[]>([]);
function handleDivClick(e: React.MouseEvent) {
setClicks((prev) => [...prev, "Div handler triggered"]);
}
function handleSpanClick(e: React.MouseEvent) {
setClicks((prev) => [...prev, `Span clicked at (${e.clientX}, ${e.clientY})`]);
e.stopPropagation(); // Stop from bubbling further in React tree
}
return (
<div onClick={handleDivClick}>
<p>Click events from the portal bubble up to this parent div.</p>
{createPortal(
<span
onClick={handleSpanClick}
style={{
display: "inline-block",
padding: "8px 16px",
background: "#ffd700",
cursor: "pointer",
}}
>
Click me (portal)
</span>,
document.body
)}
<ul>
{clicks.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
);
}
Expected output: Clicking the yellow "Click me (portal)" span shows "Span clicked at..." and "Div handler triggered" in the list. The event bubbles from the portal span up through the React tree to the parent div — even though in the DOM the span is a child of document.body, not the div.
Common Mistakes
1. Forgetting Focus Management in Modals
Portals do not automatically manage focus. Without focus trapping, pressing Tab can move focus behind the modal. Always trap focus and return focus on close.
2. Not Cleaning Up Portal Elements
Portals render to a DOM node that persists until explicitly removed. Ensure the portal component cleans up its rendered content when unmounting.
3. Using Portals When CSS Is Sufficient
Before reaching for a portal, try CSS solutions like position: fixed, z-index, and isolation: isolate. Portals add complexity and should be used only when parent CSS constraints cannot be overridden.
Practice Questions
Challenge
Build a context menu component that: appears on right-click, positions at the mouse cursor, closes on click outside or Escape, renders via portal to avoid clipping, and supports nested submenus. Add keyboard navigation (arrow keys, Enter, Escape).
Real-World Task
Build a notification center for a dashboard app. Use a portal to render the notification dropdown so it is not clipped by a sidebar with overflow: hidden. The dropdown should: show unread count on the bell icon, list recent notifications with timestamps, mark as read on click, and auto-close on click outside. Add a slide-in animation using CSS transitions.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where portals power modals, tooltips, and notification systems used by millions of users.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro