Portals and Modals
In this chapter, you learn how to implement modals using React's createPortal.
What you learn in this chapter
- How
createPortalworks and when to use it - How to implement an accessible modal
- Focus trapping and keyboard interaction
- Implementing confirmation dialogs and toast notifications
- Caveats around event bubbling
This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.
This chapter makes use of useEffect (Chapter 11) and useRef (Chapter 12). If you are unsure about these hooks, review the relevant chapters first.
What is createPortal
createPortal is a feature that renders child elements outside the parent component's DOM hierarchy. Use it when you want to display something visually outside the parent element, such as modals, tooltips, and dropdowns.
Why a Portal is needed
Normally, React components are rendered inside the parent's DOM element. However, this has problems.
// Problem: it is affected by CSS overflow and z-index
function Card() {
return (
<div style={{ overflow: 'hidden' }}>
<Modal /> {/* gets clipped by overflow: hidden */}
</div>
)
}
With a Portal, you can render beyond the DOM hierarchy.
Portal basics
How to use it
import { createPortal } from 'react-dom'
function Modal({ children }: { children: React.ReactNode }) {
// Render directly under document.body
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.body
)
}
The arguments of createPortal
createPortal(children, domNode, key?)
children: the React elements to renderdomNode: the target DOM element to render intokey: an optional key
Implementing a modal
A basic modal
// components/Modal.tsx
import { createPortal } from 'react-dom'
import { useEffect } from 'react'
type ModalProps = {
isOpen: boolean
onClose: () => void
children: React.ReactNode
title?: string
}
export function Modal({ isOpen, onClose, children, title }: ModalProps) {
// Close with the ESC key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
// Disable scrolling
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal content */}
<div className="relative z-10 bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
{title && (
<h2 id="modal-title" className="text-xl font-bold mb-4">
{title}
</h2>
)}
{children}
</div>
</div>,
document.body
)
}
Usage
import { useState } from 'react'
import { Modal } from './components/Modal'
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
const handleOpenModal = () => {
setIsModalOpen(true)
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleConfirm = () => {
// Run the action
setIsModalOpen(false)
}
return (
<div className="p-4">
<button
onClick={handleOpenModal}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Open modal
</button>
<Modal
isOpen={isModalOpen}
onClose={handleCloseModal}
title="Confirm"
>
<p className="mb-4">Do you want to run this action?</p>
<div className="flex gap-2 justify-end">
<button
onClick={handleCloseModal}
className="px-4 py-2 bg-gray-200 rounded"
>
Cancel
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Confirm
</button>
</div>
</Modal>
</div>
)
}
Try it: create a Modal component
Create src/components/Modal.tsx and implement open/close functionality plus closing with the ESC key.
Hint
- Import
createPortalfromreact-dom - Return
nullwhenisOpenis false - Watch the
keydownevent withuseEffect - Call
removeEventListenerin the cleanup
Answer and explanation
This answer uses a dedicated container element (modal-root), but document.body introduced in the main text also works. For the benefits of using a dedicated container, see the "Container elements for Portals" section at the end of the chapter.
<!-- Add to index.html -->
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
// src/components/Modal.tsx
import { createPortal } from 'react-dom'
import { useEffect } from 'react'
type ModalProps = {
isOpen: boolean
onClose: () => void
title?: string
children: React.ReactNode
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
{title && <h2 className="text-xl font-bold mb-4">{title}</h2>}
{children}
<button
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
>
✕
</button>
</div>
</div>,
document.getElementById('modal-root')!
)
}
export default Modal
By specifying the modal-root element as the second argument of createPortal, it is rendered as a child of #modal-root in the DOM tree. Pressing the ESC key or clicking the overlay closes the modal.
Event bubbling
An important characteristic of Portals is that events bubble through the React tree.
function Parent() {
const handleClick = () => {
console.log('Parent clicked')
}
return (
<div onClick={handleClick}>
<Child />
</div>
)
}
function Child() {
return createPortal(
<button onClick={() => console.log('Button clicked')}>
Click
</button>,
document.body
)
}
// Output when the button is clicked:
// "Button clicked"
// "Parent clicked" ← even though the DOM is separate, it bubbles in the React tree
An element created with a Portal is a child of document.body in the DOM tree, but in React's event system it is treated as a child of the original parent component. If you do not understand this, unintended event propagation may occur.
Stopping events
Stop bubbling with stopPropagation as needed.
function Modal({ onClose, children }: ModalProps) {
return createPortal(
<div onClick={onClose}>
<div onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
)
}
Accessibility
A modal's accessibility is important. In the first half of this chapter, we learned the mechanism based on createPortal + <div role="dialog">, but in production code we recommend using the native <dialog> element or a dedicated library.
The native <dialog> element
The HTML standard <dialog> element implements many of a modal's features on the browser side. It has been supported in all major browsers since 2022.
import { useEffect, useRef } from 'react'
type DialogModalProps = {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}
function DialogModal({ isOpen, onClose, children }: DialogModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen) {
dialog.showModal() // open as a modal (focus trapping is automatic too)
} else if (dialog.open) {
dialog.close()
}
}, [isOpen])
return (
<dialog ref={dialogRef} onClose={onClose}>
{children}
</dialog>
)
}
When you open a <dialog> with showModal(), all of the following are achieved automatically.
- Focus trapping: the Tab key does not leave the
<dialog> - Focus restoration: after closing, focus returns to the element that had focus before opening
- Closing with the Esc key: the browser fires a
closeevent - Top-layer rendering: always displayed in front, without worrying about
z-index - The
::backdroppseudo-element: you can style the background overlay with CSS
Note that scrolling of the background page is not locked automatically. Use CSS such as html:has(dialog:modal) { overflow: hidden; } in combination (this plays the same role as manipulating body.style.overflow in the Modal implementation in the first half).
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
showModal() and show() are different. show() is a non-modal display that does not do focus trapping or make the background inert. When you want to block user interaction, as with a confirmation dialog, always use showModal().
A hand-written focus trap (for learning)
The following is an implementation example for educational purposes that uses useEffect + useRef. It helps you understand the mechanism when you do not use the <dialog> element.
In production, instead of a hand-written useFocusTrap, we recommend using a native <dialog>, a dedicated library such as focus-trap, or a headless component such as Radix UI / Headless UI. A hand-written implementation cannot fully cover the many edge cases you encounter in practice, such as hidden elements, disabled elements, iframes, and elements spanning across Portals.
Focus trap
While the modal is open, trap focus inside the modal.
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen || !containerRef.current) return
const container = containerRef.current
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
// Focus the first element
firstElement?.focus()
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
container.addEventListener('keydown', handleTab)
return () => container.removeEventListener('keydown', handleTab)
}, [isOpen])
return containerRef
}
// Usage (ModalProps reuses the definition from "A basic modal" earlier)
function AccessibleModal({ isOpen, onClose, children }: ModalProps) {
const containerRef = useFocusTrap(isOpen)
if (!isOpen) return null
return createPortal(
<div
ref={containerRef}
role="dialog"
aria-modal="true"
>
{children}
</div>,
document.body
)
}
ARIA attributes
<div
role="dialog" // indicates that it is a dialog
aria-modal="true" // a modal dialog
aria-labelledby="modal-title" // the ID of the title element
aria-describedby="modal-desc" // the ID of the description element
>
<h2 id="modal-title">Title</h2>
<p id="modal-desc">Description</p>
</div>
Implementing a confirmation dialog
Implement a commonly used confirmation dialog with a custom hook.
// hooks/useConfirm.tsx
import { useState, useCallback, type ReactNode } from 'react'
import { Modal } from '../components/Modal'
type ConfirmOptions = {
title?: string
message: ReactNode
confirmText?: string
cancelText?: string
}
export function useConfirm() {
const [isOpen, setIsOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions | null>(null)
const [resolvePromise, setResolvePromise] = useState<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setIsOpen(true)
return new Promise((resolve) => {
setResolvePromise(() => resolve)
})
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
resolvePromise?.(true)
}, [resolvePromise])
const handleCancel = useCallback(() => {
setIsOpen(false)
resolvePromise?.(false)
}, [resolvePromise])
const ConfirmDialog = useCallback(() => {
if (!options) return null
return (
<Modal isOpen={isOpen} onClose={handleCancel} title={options.title}>
<p className="mb-4">{options.message}</p>
<div className="flex gap-2 justify-end">
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
{options.cancelText ?? 'Cancel'}
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{options.confirmText ?? 'Confirm'}
</button>
</div>
</Modal>
)
}, [isOpen, options, handleConfirm, handleCancel])
return { confirm, ConfirmDialog }
}
// Usage
function DeleteButton({ onDelete }: { onDelete: () => void }) {
const { confirm, ConfirmDialog } = useConfirm()
const handleClick = async () => {
const confirmed = await confirm({
title: 'Confirm deletion',
message: 'Do you want to delete this item? This action cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel'
})
if (confirmed) {
onDelete()
}
}
return (
<>
<button
onClick={handleClick}
className="px-4 py-2 bg-red-500 text-white rounded"
>
Delete
</button>
<ConfirmDialog />
</>
)
}
Try it: implement a confirmation dialog
Create a useConfirm hook and use it with a delete button. Make it return the confirm/cancel result using a Promise.
Hint
- Manage the open/close state and options with
useState - Return a
Promisefrom theconfirmfunction - Save the resolve function with
setResolver(() => resolve) - Call
resolver(true/false)on confirm/cancel
Answer and explanation
// src/hooks/useConfirm.tsx
import { useState, useCallback } from 'react'
import { Modal } from '../components/Modal'
type ConfirmOptions = {
title?: string
message: string
confirmText?: string
cancelText?: string
}
export function useConfirm() {
const [isOpen, setIsOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions | null>(null)
const [resolver, setResolver] = useState<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setIsOpen(true)
return new Promise((resolve) => {
setResolver(() => resolve)
})
}, [])
const handleConfirm = useCallback(() => {
setIsOpen(false)
resolver?.(true)
}, [resolver])
const handleCancel = useCallback(() => {
setIsOpen(false)
resolver?.(false)
}, [resolver])
const ConfirmDialog = () => {
if (!options) return null
return (
<Modal isOpen={isOpen} onClose={handleCancel} title={options.title}>
<p className="text-gray-600 mb-6">{options.message}</p>
<div className="flex gap-3 justify-end">
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
{options.cancelText ?? 'Cancel'}
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
{options.confirmText ?? 'Confirm'}
</button>
</div>
</Modal>
)
}
return { confirm, ConfirmDialog }
}
// Usage
function App() {
const [items, setItems] = useState([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
])
const { confirm, ConfirmDialog } = useConfirm()
const handleDelete = async (item: { id: number; name: string }) => {
const confirmed = await confirm({
title: 'Confirm deletion',
message: `Do you want to delete "${item.name}"?`,
confirmText: 'Delete',
cancelText: 'Cancel'
})
if (confirmed) {
setItems(prev => prev.filter(i => i.id !== item.id))
}
}
const createDeleteHandler = (item: { id: number; name: string }) => () => {
handleDelete(item)
}
return (
<div className="p-8">
{items.map(item => (
<div key={item.id} className="flex justify-between p-2">
<span>{item.name}</span>
<button onClick={createDeleteHandler(item)}>Delete</button>
</div>
))}
<ConfirmDialog />
</div>
)
}
export default App
By returning a Promise, the useConfirm hook lets you await the confirmation result. By setting a function with setResolver(() => resolve), you make it possible to call resolve later.
Toast notifications
Portals are also well suited for toast notifications.
// contexts/ToastContext.tsx
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type ToastType = 'success' | 'error' | 'info'
type Toast = {
id: string
type: ToastType
message: string
}
type ToastContextType = {
showToast: (type: ToastType, message: string) => void
}
const ToastContext = createContext<ToastContextType | undefined>(undefined)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const showToast = useCallback((type: ToastType, message: string) => {
const id = crypto.randomUUID()
setToasts(prev => [...prev, { id, type, message }])
// Auto-remove after 3 seconds
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 3000)
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const createRemoveHandler = (id: string) => () => {
removeToast(id)
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{createPortal(
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`px-4 py-3 rounded shadow-lg text-white flex items-center gap-2 ${
toast.type === 'success' ? 'bg-green-500' :
toast.type === 'error' ? 'bg-red-500' :
'bg-blue-500'
}`}
>
<span>{toast.message}</span>
<button
onClick={createRemoveHandler(toast.id)}
className="ml-2 hover:opacity-70"
>
✕
</button>
</div>
))}
</div>,
document.body
)}
</ToastContext.Provider>
)
}
// Usage
function App() {
return (
<ToastProvider>
<SaveButton />
</ToastProvider>
)
}
function SaveButton() {
const { showToast } = useToast()
const handleSave = async () => {
try {
await saveData()
showToast('success', 'Saved')
} catch {
showToast('error', 'Failed to save')
}
}
return <button onClick={handleSave}>Save</button>
}
Tooltips
Portals can also be used to implement tooltips.
import { useState, useRef } from 'react'
import { createPortal } from 'react-dom'
type TooltipProps = {
content: string
children: React.ReactNode
}
export function Tooltip({ content, children }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false)
const [position, setPosition] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLSpanElement>(null)
const showTooltip = () => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setPosition({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2
})
}
setIsVisible(true)
}
const hideTooltip = () => {
setIsVisible(false)
}
return (
<>
<span
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
</span>
{isVisible && createPortal(
<div
className="absolute z-50 px-2 py-1 text-sm text-white bg-gray-800 rounded -translate-x-1/2"
style={{ top: position.top, left: position.left }}
role="tooltip"
>
{content}
<div className="absolute -top-1 left-1/2 -translate-x-1/2 border-4 border-transparent border-b-gray-800" />
</div>,
document.body
)}
</>
)
}
// Usage
<Tooltip content="Click to copy">
<button>Copy</button>
</Tooltip>
Caveats about position calculation
The coordinates obtained from getBoundingClientRect() are values relative to the viewport at the moment of display. Therefore, the position may be off in the following cases.
- The window was scrolled while displayed: recalculation is needed on the
scrollevent - The window was resized while displayed: recalculation is needed on the
resizeevent or withResizeObserver - The trigger element's own size changes dynamically: detect with
ResizeObserver - Near the edge of the viewport: the tooltip may overflow off-screen, so edge detection and position flipping are needed
For a simple tooltip, a design that suppresses scrolling while displayed is an option, but if the requirements are complex, using a dedicated library such as Floating UI (formerly Popper.js) is reliable. It handles placement calculation, overflow avoidance, arrow rendering, and more all at once.
Container elements for Portals
You can also use a dedicated container other than document.body.
// index.html
<body>
<div id="root"></div>
<div id="modal-root"></div> <!-- modal-only -->
<div id="toast-root"></div> <!-- toast-only -->
</body>
// Modal.tsx
const modalRoot = document.getElementById('modal-root')!
function Modal({ children }: { children: React.ReactNode }) {
return createPortal(children, modalRoot)
}
Summary
createPortalcan render outside the DOM hierarchy- Useful for modals, tooltips, toasts, and so on
- Events bubble along the React tree
- Do not forget accessibility (focus trapping, ARIA attributes)
- Using dedicated container elements keeps the DOM structure organized
In the next chapter, you will learn about state management with Zustand.
What to read next
We move on to state management and data fetching. You will learn state management with Zustand, data fetching with TanStack Query, and routing with React Router.