How would you build a reusable accessible Dialog component?
Render via Portal, lock body scroll, trap focus inside the dialog, restore focus on close, close on Esc + backdrop click, and use role='dialog' aria-modal='true' with labelled title.
A modal looks trivial until you list everything that has to be right: portal rendering, scroll locking, focus trapping, focus restoration, ESC handling, click-outside dismiss, ARIA, and animation. Real-world libraries (Radix Dialog, Headless UI Dialog) exist precisely because most teams botch at least one.
1. Render with a Portal. createPortal(children, document.body) escapes whatever overflow/transform parent you're in. Modals nested under overflow: hidden containers get clipped — portals fix that.
2. Lock body scroll. When the modal opens, set document.body.style.overflow = 'hidden'. On close, restore. iOS Safari needs position: fixed; top: -scrollY to truly stop scroll behind the modal — note the trade-off (resets scroll on close unless you save/restore Y).
3. Trap focus. Tab and Shift+Tab must cycle inside the modal. On open, query focusable elements (button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])), and on each Tab keydown, wrap from last → first / first → last. Otherwise keyboard users escape into the underlying page.
4. Initial focus. On open, move focus to the first focusable element (or a designated autoFocus ref, like the primary button). Don't just leave focus where the trigger was.
5. Restore focus on close. Save document.activeElement before opening; on close, call previouslyFocused.focus(). Without this, screen reader users get dumped at the top of the page.
6. ARIA. role="dialog", aria-modal="true", aria-labelledby="title-id" (the modal's heading), and optionally aria-describedby for the body. The native <dialog> element gives you most of this for free but has historical browser quirks — most teams build their own.
7. Dismiss UX.
- Esc key closes (unless a confirm-discard is needed).
- Backdrop click closes (some apps disable for destructive flows).
- X button closes — labelled
aria-label="Close".
8. Stacking. Multiple modals: maintain a stack and only the top one handles Esc. z-index must be high enough; CSS isolation: isolate on the portal root avoids z-fighting.
9. Animation. framer-motion AnimatePresence or CSS transitions on enter/exit. Don't unmount mid-animation — wait for transition end before removing from DOM.
10. Use a library in production. @radix-ui/react-dialog, @headlessui/react, or react-aria's useDialog. Roll your own only when you must.
Code
function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusables?.[0]?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
if (e.key !== "Tab" || !focusables || focusables.length === 0) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby={titleId}
className="rounded-lg bg-white p-6 shadow-xl">
<h2 id={titleId}>{title}</h2>
{children}
</div>
</div>,
document.body
);
}Follow-up questions
- •Why use onMouseDown not onClick for backdrop dismiss?
- •How do you handle nested modals (stack management)?
- •Why save/restore previously focused element?
- •What's wrong with the native <dialog> element?
Common mistakes
- •Forgetting to restore focus on close — screen reader users lose context.
- •No focus trap — Tab escapes into the underlying page.
- •Closing on click of inner content — using onClick on the backdrop without target check.
- •Locking body scroll without saving original overflow — breaks Tailwind defaults.
Performance considerations
- •Portal target stays mounted — render the modal lazily (only when open) to avoid extra DOM.
- •Animation should use transform/opacity (composited) not width/height.
Edge cases
- •iOS Safari background scroll bleed — needs position:fixed body trick.
- •Modal opens during a transition — focus first focusable AFTER paint or it focuses an invisible element.
- •Two Esc-handlers compete (modal + autocomplete) — stopPropagation on the topmost.
Real-world examples
- •Stripe's checkout modal, GitHub's confirm dialogs, Linear's command palette — all use Radix or hand-rolled equivalents.