Back to Machine Coding
Machine Coding
medium
mid

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.

7 min read·~40 min to think through

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

tsx
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
  );
}
Minimal accessible Modal — portal, focus trap, scroll lock, restore focus

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.

Senior engineer discussion

Senior signal: focus trap correctness, focus restoration, ARIA labelling, and knowledge of why <dialog> is rarely used in practice.