renderpx
Theme: auto

Modal Management

Open and close modals (dialogs) in a predictable way: focus trap, Escape, optional URL sync, and support for stacked or sequential modals.

The problem I keep seeing

Modals need to trap focus, close on Escape and (often) click-outside, and be announced to screen readers. Managing which modal is open from deep in the tree leads to prop drilling or scattered state. You want a single place to render modals and a clear way to open/close them from anywhere.

Naive approach

Local useState for open/close and modal content in the same component that triggers it. Fine for one screen; when multiple components need to open modals or you need escape/focus behavior, it gets duplicated or inconsistent.

tsx
Loading...

First improvement

Lift modal state to a context (or root): store “which modal is open” and its props (e.g. { type: 'user-details', user }). Render the active modal in one place (e.g. layout). Any child can call setModal(...) to open. Add focus trap and Escape inside each modal component.

tsx
Loading...

Remaining issues

  • Accessibility: Focus trap (tab stays inside modal), focus return on close, aria-modal, role="dialog", and a visible title for screen readers.
  • Escape and click-outside: Consistent behavior; some modals may want to block dismiss (e.g. “Unsaved changes”).
  • Stacking: If a modal can open another (e.g. confirm inside details), define whether they stack (Escape closes top) or replace.

Production pattern

Use Radix UI Dialog (or Headless UI Modal). It provides overlay, content, focus trap, Escape, and aria-*. Control open state via open and onOpenChange. Keep a small “modal registry” in context: openModal({ type, props }) and render the matching component in a single portal. For stacked modals, keep an array of open modals and render the top one; pass onClose to pop the stack.

tsx
Loading...
Stacking modalstsx
Loading...

When I use this

  • Details / edit overlay: Click a row → modal with full record; close and return to list.
  • Confirmations: “Delete?”, “Discard changes?” with Cancel / Confirm.
  • Skip when: A slide-over or inline expand is enough; prefer those for less disruptive flows.

Gotchas

  • Scroll lock: When the modal is open, body scroll should be locked; Radix and similar do this.
  • URL: If the modal is “the page” (e.g. /users/1 in a modal), sync open state with the route and use router.back() or replace to close.
  • Focus return: When closing, focus should return to the element that opened the modal so keyboard users aren’t lost.

Toasts → · All patterns