Build an accessible Modal / 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
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.