How would you design a modal manager system in React?

Advanced20 min interview
Skills tested:
Portal RenderingFocus ManagementContext API ArchitectureAccessibilityModal Stacking LogicKeyboard Interaction Handling

Advertisement

🧩 Scenario

You need a global modal manager so any component in the app can open a modal without manually placing modal components everywhere. Requirements: - Open any modal from anywhere (context API) - Support multiple modal types (alert, confirm, custom forms) - Stack modals (open one over another) - ESC to close - Click outside to close - Focus trap inside modal - Render via portal to avoid z-index issues

🧠 Architecture Walkthrough

Why Context + Provider Instead of Local State?

Placing modal state inside individual components creates a coordination problem the moment two features both need to trigger a modal. A confirmation dialog triggered from a data table, a form modal opened from a sidebar action, and an alert from an error boundary all need the same rendering pipeline.

If each feature owns its own modal state, you end up with duplicated backdrop elements, competing z-indexes, and no way to enforce consistent focus behavior across the application.

The Context + Provider pattern solves this by making the modal stack a singleton one ModalProvider owns openModal, closeModal, and the stack array, and every consumer calls into the same queue regardless of where it lives in the component tree.

The stack itself is an array of { id, content, type } objects, which means you can push onto it from anywhere and pop from the top without any consumer needing to know about any other modal that might be open.

Why Portal Rendering Is Non-Negotiable

Rendering a modal inside the component that triggers it creates a hidden z-index trap. Even if you set z-index: 9999, that value is relative to the stacking context of the nearest positioned ancestor.

If a parent component has transform, filter, or will-change set common in animated layouts your modal will be clipped inside that context and the backdrop will never cover the full viewport.

Rendering via createPortal(modalStack, document.body) escapes the component tree entirely. The modal's DOM node lives directly under <body>, so its stacking context is always the root, and position: fixed works as expected.

This is especially critical in applications that use Framer Motion on layout containers or have sticky headers with their own stacking contexts.

How Focus Trapping and Restoration Work Together

A modal that does not trap focus is an accessibility failure. Screen reader users and keyboard-only users will Tab out of the modal and interact with the page underneath, which is both confusing and potentially dangerous for destructive action dialogs.

The implementation captures document.activeElement the moment a modal becomes the top of the stack, stores it in a ref, and restores focus to that element when the modal unmounts.

Inside the modal, the keydown listener intercepts Tab and Shift+Tab: if the user is on the last focusable element and presses Tab forward, focus wraps back to the first; if on the first and pressing Shift+Tab, focus wraps to the last.

Only the top modal installs this listener the isTop prop gates the effect which means nested modals each manage focus for their own layer without conflicts.

The 10ms setTimeout before calling ref.current?.focus() is a small but important detail: the modal needs one paint cycle to exist in the DOM before focus can be moved into it.

💡 Key Code Explained

const openModal = useCallback((content, type = 'default') => {
  const id = Math.random().toString(36).slice(2);
  setStack((s) => [...s, { id, content, type }]);
  return id;
}, []);

const closeModal = useCallback((id) => {
  setStack((s) => s.filter((m) => m.id !== id));
}, []);

const closeTop = useCallback(() => {
  setStack((s) => s.slice(0, -1));
}, []);

openModal returns the generated id back to the caller. This matters when a caller needs to close a specific modal programmatically rather than always closing the top for instance, an async operation that completes and needs to dismiss its own loading modal even if another modal was opened on top of it in the meantime.

closeTop uses slice(0, -1) rather than filtering by id, which is intentional: ESC and backdrop clicks should always dismiss the topmost modal without needing to know its id. closeModal by id is reserved for explicit dismissal from within modal content itself (the onClose prop pattern).

Both callbacks are wrapped in useCallback with empty deps because setStack from useState is stable across renders leaving them out of useCallback would cause every context consumer to re-render on every stack change.

useEffect(() => {
  if (isTop) {
    lastFocused.current = document.activeElement;
    setTimeout(() => {
      ref.current?.focus();
    }, 10);
  }

  const onKey = (e) => {
    if (isTop && e.key === 'Escape') {
      closeTop();
    }

    if (isTop && e.key === 'Tab') {
      const focusableElements = ref.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
      );

      if (focusableElements?.length > 0) {
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            e.preventDefault();
            lastElement.focus();
          }
        } else {
          if (document.activeElement === lastElement) {
            e.preventDefault();
            firstElement.focus();
          }
        }
      }
    }
  };

  if (isTop) {
    window.addEventListener('keydown', onKey);
  }

  return () => {
    if (isTop) {
      window.removeEventListener('keydown', onKey);
    }
  };
}, [isTop, closeTop]);

The entire focus management logic is gated on isTop. When modal B opens on top of modal A, modal A's isTop becomes false and its keydown listener is removed otherwise both modals would respond to ESC simultaneously.

The focusable element query uses a CSS selector that covers all standard interactive elements plus any element with an explicit tabindex. The selector deliberately excludes [tabindex="-1"] because those elements are programmatically focusable but not part of the natural tab order.

A junior developer would likely omit the e.preventDefault() calls on the Tab key inside the trap, which would cause the browser to also apply its default Tab behavior on top of the manual focus move, resulting in a double-skip.

⚖️ Tradeoffs

ApproachProCon
Context + stack (chosen)Single source of truth, supports stacking natively, any component can open a modalRequires wrapping the app in a provider; TypeScript generics for content type are tricky
Local modal state per featureSimple to reason about, no provider neededCannot stack, duplicate backdrop logic, inconsistent focus behavior
Imperative API (modal.open())No React context needed, callable from non-React code like event handlersHarder to co-locate modal content with its trigger, requires global singleton outside React

🎯 What Interviewers Actually Check

  • Mentions portal rendering unprompted and explains why it matters for stacking contexts, not just z-index
  • Distinguishes between closeTop (for ESC/backdrop) and closeModal(id) (for self-dismissal) and explains why both are needed
  • Describes saving and restoring document.activeElement as part of the accessibility contract
  • Explains that only the top modal should own the keyboard listener, not all open modals simultaneously
  • Notes that the alert type blocking backdrop dismissal is a type-level constraint, not a prop on every call site

❓ Follow-Up Questions

  1. How would you animate modal enter and exit with Framer Motion when the modal is rendered via a portal and unmounts immediately on close?
  2. If a server action inside a form modal throws an error, how would you show an error alert modal on top of the form modal without closing the form?
  3. How would you write a test for the focus trap behavior specifically that Tab wraps from the last focusable element back to the first?
  4. Your modal stack can grow unbounded if a user triggers modals in a loop. How would you add a max-stack limit without breaking the existing API?
  5. Your PM says every modal needs an animation, but designers want each modal type to have a different entrance animation how do you extend the type system to support this without coupling animation logic to the provider?

🎮 Live Demo

📝 Summary

A production modal manager is built around three interlocking ideas: a context-owned stack that any component can push onto, portal rendering that escapes stacking context traps, and per-modal focus management that hands keyboard ownership exclusively to the topmost layer.

The type field on each stack entry is what makes behavioural differences like the alert's backdrop immunity expressible at the data level rather than through ad-hoc boolean props. Focus trapping and restoration are not cosmetic polish they are the accessibility contract that makes the system usable for keyboard and screen reader users.

Getting these three pieces right is what separates a modal system that works in demos from one that survives a production codebase with complex layouts and multiple concurrent user flows.

Frequently Asked Questions

Why use a modal manager instead of local modals?

Local modals don't scale when multiple features need modals. A central manager standardizes behavior and avoids nested portal issues.

How do you keep accessibility strong?

Use focus trapping, ESC to close, ARIA labels, and restore focus to the trigger element.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement