Modals using Portals (practical use)
In this page:
Building a Basic Portal-Based Modal
Combining createPortal with conditional rendering (only rendering the portal's content when isOpen is true) gives you a working modal: a backdrop plus a content box, rendered into document.body, appearing and disappearing based on a simple boolean state.
Note: Render null when isOpen is false, rather than always rendering and hiding with CSS, to keep the DOM clean when the modal is closed.
Warning: Without a backdrop element (even just a semi-transparent overlay), the modal can look like it's floating disconnected from the rest of the UI.
Example: Building a Basic Portal-Based Modal
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }} onClick={onClose}>
<div style={{ background: "white", padding: "16px", margin: "50px auto", width: "200px" }} onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}
function App() {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}><p>Modal content</p></Modal>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
Closing with the Escape Key
A well-behaved modal should also close when the user presses Escape, not just when clicking the backdrop. Adding a keydown event listener inside a useEffect (only while the modal is open) implements this, with cleanup removing the listener when the modal closes or unmounts.
Note: Only add the keydown listener while isOpen is true, and include isOpen in the effect's dependency array to add/remove it correctly.
Warning: Forgetting to remove this listener in cleanup means it stays active even after the modal closes, potentially responding to Escape presses when it shouldn't.
Example: Closing with the Escape Key
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function Modal({ isOpen, onClose, children }) {
React.useEffect(() => {
if (!isOpen) return;
const handleKey = e => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }}>
<div style={{ background: "white", padding: "16px", margin: "50px auto", width: "220px" }}>
{children}<p style={{fontSize: "12px"}}>(Press Escape to close)</p>
</div>
</div>,
document.body
);
}
function App() {
const [isOpen, setIsOpen] = React.useState(true);
return <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}><p>Try pressing Escape</p></Modal>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
Preventing Background Scroll While Open
While a modal is open, the page behind it scrolling can feel disorienting. Setting document.body.style.overflow to hidden while the modal is open, and restoring it in the effect's cleanup, locks background scrolling for the duration.
Note: Always restore the original overflow value (or just remove the style) in cleanup, so closing the modal doesn't permanently lock scrolling.
Warning: Forgetting to restore the overflow style in cleanup leaves the whole page unable to scroll even after the modal has closed.
Example: Preventing Background Scroll While Open
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
function Modal({ isOpen, children }) {
React.useEffect(() => {
if (isOpen) document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)" }}>
<div style={{ background: "white", padding: "16px", margin: "50px auto", width: "220px" }}>{children}</div>
</div>,
document.body
);
}
function App() {
return <Modal isOpen={true}><p>Background scroll is locked while I'm open</p></Modal>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
- Not handling the Escape key or backdrop click to close the modal, trapping the user inside it.
- Forgetting to prevent body scroll while the modal is open, letting the page scroll awkwardly behind it.
- Not managing focus — a truly accessible modal should trap keyboard focus inside it while open.
- A modal is a natural, practical use case for React Portals, since it needs to visually sit above everything else.
- A real modal implementation also needs a backdrop, a close mechanism (button, Escape key, backdrop click), and ideally focus management.
- Conditionally rendering the portal (only when isOpen is true) is simpler than always rendering and toggling visibility with CSS.
- Using createPortal keeps the modal's JSX colocated with the component that opens it, while its DOM lives at the top of the page.
Available since React 16.0 (Portals introduced).
Chapter Quiz — Complete all 14 topics to unlock
0/14 topics done
Complete these topics first:
- Error Boundaries
- React Portals
- Modals using Portals (practical use)
- React Suspense
- Code Splitting with React.lazy
- Introduction to Server Components
- Introduction to Next.js (server-side React)
- Using React with TypeScript
- Scalable Folder Architecture
- Common React Design Patterns
- Component Documentation with Storybook
- Accessibility (a11y) in React
- i18n with react-i18next
- React Security Best Practices