React cookbook · Intermediate
Build an accessible modal dialog
Show a confirmation dialog that traps focus, closes with Escape and restores focus afterwards.
Approach
Use the native <dialog> element with showModal(): browsers handle focus trapping, the backdrop, Escape to close and inertness of the rest of the page. Label it with aria-labelledby, move focus to the safest action, and return focus to the trigger on close.
Solution
import { useEffect, useId, useRef } from "react";
type Props = { open: boolean; title: string; children: React.ReactNode; onConfirm: () => void; onClose: () => void };
export function ConfirmDialog({ open, title, children, onConfirm, onClose }: Props) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
const opener = useRef<Element | null>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) {
opener.current = document.activeElement;
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
return (
<dialog
ref={ref}
aria-labelledby={titleId}
onClose={() => {
onClose();
(opener.current as HTMLElement | null)?.focus(); // restore focus
}}
>
<h2 id={titleId}>{title}</h2>
<div>{children}</div>
<form method="dialog">
<button autoFocus value="cancel">Cancel</button>
<button value="confirm" onClick={onConfirm}>Delete</button>
</form>
</dialog>
);
}
Pitfalls
- Div-based modals without focus trapping or Escape handling.
- Autofocusing the destructive action.
- Not restoring focus to the triggering button.
Variations
- Use React Aria's
useDialog/Modalor Radix Dialog for complex needs. - Render into a portal when stacking contexts interfere.
Further reading: MDN: <dialog>
Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
More React recipes
- Add optimistic updates to a todo list
- Build infinite scroll with TanStack Query
- Call a cookie-authenticated API with CSRF protection
- Debounce a search input in React
- Handle errors gracefully with error boundaries
- Implement dark mode that follows the system
- Poll a long-running job's status
- Protect routes in a React SPA