Modal
An overlay dialog that appears on top of the page content to capture user attention for confirmations, forms, or important information.
Use Cases
- Confirm destructive actions (delete account, remove item)
- Display forms without navigating away (edit profile, new item)
- Show detailed previews (image lightbox, document viewer)
- Alert users to critical information (session expiry, errors)
- Collect quick input (rename, add a tag, leave a comment)
- Display terms of service or consent dialogs
Simplest Implementation
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
} else {
dialog.close();
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
{children}
</dialog>
);
}Uses the native <dialog> element which provides built-in backdrop, focus trapping, and Escape key handling. The backdrop: Tailwind modifier styles the overlay behind the dialog.
Variations
With Title and Close Button
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-md rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
>
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Close"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-4">{children}</div>
</dialog>
);
}Separates header and body with a border. The close button uses aria-label for accessibility since it only contains an icon.
Confirmation Modal
"use client";
import { useEffect, useRef } from "react";
interface ConfirmModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
loading?: boolean;
}
export function ConfirmModal({
open, onClose, onConfirm, title, message, confirmLabel = "Confirm", loading,
}: ConfirmModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-gray-600">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={loading}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{loading ? "Deleting..." : confirmLabel}
</button>
</div>
</dialog>
);
}A purpose-built confirmation dialog. The destructive action button is red, and the cancel button is visually lighter to guide the user toward the safe choice.
Modal with Footer Actions
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer: React.ReactNode;
}
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className="w-full max-w-lg rounded-xl bg-white p-0 shadow-xl backdrop:bg-black/50"
>
<div className="border-b px-6 py-4">
<h2 className="text-lg font-semibold">{title}</h2>
</div>
<div className="px-6 py-4">{children}</div>
<div className="flex justify-end gap-3 border-t px-6 py-4">{footer}</div>
</dialog>
);
}A slot-based layout with footer as a prop. This lets the parent provide any combination of buttons without the modal needing to know about specific actions.
Animated Modal
"use client";
import { useEffect, useRef, useState } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
requestAnimationFrame(() => setVisible(true));
} else {
setVisible(false);
const timer = setTimeout(() => dialog.close(), 200);
return () => clearTimeout(timer);
}
}, [open]);
return (
<dialog
ref={dialogRef}
onClose={onClose}
className={`w-full max-w-md rounded-xl bg-white p-6 shadow-xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
visible ? "scale-100 opacity-100 backdrop:opacity-100" : "scale-95 opacity-0 backdrop:opacity-0"
}`}
>
{children}
</dialog>
);
}A two-phase approach: showModal() makes the dialog visible in the DOM, then requestAnimationFrame triggers the CSS transition. On close, the animation plays before dialog.close() removes it.
Prevent Close on Backdrop Click
"use client";
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
closeOnBackdrop?: boolean;
children: React.ReactNode;
}
export function Modal({ open, onClose, closeOnBackdrop = true, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
open ? dialog.showModal() : dialog.close();
}, [open]);
function handleClick(e: React.MouseEvent<HTMLDialogElement>) {
if (!closeOnBackdrop) return;
const rect = dialogRef.current?.getBoundingClientRect();
if (!rect) return;
const clickedOutside =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (clickedOutside) onClose();
}
return (
<dialog
ref={dialogRef}
onClose={onClose}
onClick={handleClick}
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50"
>
{children}
</dialog>
);
}The native <dialog> doesn't close on backdrop click by default with showModal(). This checks if the click coordinates are outside the dialog's bounding rect to simulate backdrop click-to-close behavior.
Complex Implementation
"use client";
import { useEffect, useRef, useState, useCallback, createContext, useContext } from "react";
import { createPortal } from "react-dom";
// --- Context for nested access ---
interface ModalContextValue {
close: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModal must be used inside a Modal");
return ctx;
}
// --- Modal Component ---
interface ModalProps {
open: boolean;
onClose: () => void;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
initialFocusRef?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
}
export function Modal({
open,
onClose,
closeOnBackdrop = true,
closeOnEscape = true,
initialFocusRef,
children,
}: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [visible, setVisible] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open) {
dialog.showModal();
requestAnimationFrame(() => {
setVisible(true);
if (initialFocusRef?.current) {
initialFocusRef.current.focus();
}
});
} else {
setVisible(false);
const timer = setTimeout(() => dialog.close(), 200);
return () => clearTimeout(timer);
}
}, [open, initialFocusRef]);
const handleCancel = useCallback(
(e: React.SyntheticEvent) => {
if (!closeOnEscape) {
e.preventDefault();
return;
}
onClose();
},
[closeOnEscape, onClose]
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (!closeOnBackdrop) return;
const rect = dialogRef.current?.getBoundingClientRect();
if (!rect) return;
const outside =
e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom;
if (outside) onClose();
},
[closeOnBackdrop, onClose]
);
// Lock body scroll when open
useEffect(() => {
if (open) {
const scrollY = window.scrollY;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
return () => {
document.body.style.position = "";
document.body.style.top = "";
document.body.style.width = "";
window.scrollTo(0, scrollY);
};
}
}, [open]);
if (!mounted) return null;
return createPortal(
<ModalContext.Provider value={{ close: onClose }}>
<dialog
ref={dialogRef}
onCancel={handleCancel}
onClose={onClose}
onClick={handleClick}
className={`w-full max-w-lg rounded-xl bg-white p-0 shadow-2xl transition-all duration-200 backdrop:bg-black/50 backdrop:transition-opacity backdrop:duration-200 ${
visible
? "translate-y-0 scale-100 opacity-100 backdrop:opacity-100"
: "translate-y-2 scale-95 opacity-0 backdrop:opacity-0"
}`}
aria-modal="true"
>
{children}
</dialog>
</ModalContext.Provider>,
document.body
);
}
// --- Compound parts ---
export function ModalHeader({ children }: { children: React.ReactNode }) {
const { close } = useModal();
return (
<div className="flex items-center justify-between border-b px-6 py-4">
<h2 className="text-lg font-semibold">{children}</h2>
<button
onClick={close}
aria-label="Close"
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
export function ModalBody({ children }: { children: React.ReactNode }) {
return <div className="px-6 py-4">{children}</div>;
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
return <div className="flex justify-end gap-3 border-t px-6 py-4">{children}</div>;
}Key aspects:
- Native
<dialog>withshowModal()— provides built-in focus trapping, Escape key handling, andaria-modalsemantics. No need to implement a custom focus trap. - Compound component pattern —
ModalHeader,ModalBody,ModalFooteraccess the close function through context, keeping the API composable. - Body scroll lock — fixes the body position while preserving scroll position. On close, it restores the exact scroll offset so the user doesn't lose their place.
- Animation with
requestAnimationFrame— the dialog is shown first, then animated in on the next frame. On close, the animation plays for 200ms beforedialog.close()is called. onCancelintercept — the native dialog fires acancelevent on Escape. WhencloseOnEscapeis false,preventDefault()blocks it.- Portal via
createPortal— renders the dialog atdocument.bodyto avoid z-index stacking context issues from parent components. initialFocusRef— allows the caller to specify which element should receive focus when the modal opens (e.g., a specific input field).
Gotchas
-
Forgetting
showModal()vsshow()—show()opens the dialog without a backdrop and without focus trapping. Always useshowModal()for modal dialogs. -
Backdrop click doesn't close by default — Unlike many libraries, the native
<dialog>withshowModal()does not close when clicking the backdrop. You must implement this yourself by checking click coordinates. -
Scroll bleed-through — The page behind the modal can still scroll on mobile Safari. Use the body scroll lock pattern (fixed position + saved scroll offset) to prevent this.
-
Nested modals — Opening a second
<dialog>while one is already open can cause focus trap conflicts. Avoid stacking modals; use a sheet or inline expansion instead. -
Missing
onClosehandler — If you don't setonClose, pressing Escape closes the dialog visually but your React state staysopen: true, causing a desync. Always sync theonClosecallback. -
Animation on first mount — If the dialog is open on mount, the animation plays from the initial state. Use
requestAnimationFrameor amountedflag to skip the entrance animation when appropriate.