Machine-Coding: Build a Modal Manager

Updated · techinterview.org

The modal manager round tests whether the candidate can build a robust, accessible modal system from scratch. Modals are deceptively complex: stacking multiple modals, returning focus to the originating element on close, trapping focus inside the modal, ESC handling, scroll lock, and accessibility-correct ARIA attributes are all part of the round.

This piece walks through the implementation patterns, the accessibility requirements, and what interviewers grade.

The typical prompt

  • “Build a modal component. Multiple modals can be open simultaneously, stacked.”
  • “Implement a modal manager with React Portals.”
  • “Build a confirmation dialog system.”

What interviewers grade

  • The modal opens, shows over the content, and can be closed.
  • The ESC key closes the topmost modal, not all of them.
  • Focus moves into the modal on open and returns to the trigger on close.
  • Tab cycles within the modal and doesn’t escape to background content.
  • Clicking the backdrop outside the modal usually closes it, though this is configurable.
  • The page behind doesn’t scroll while a modal is open.
  • Multiple modals stack correctly with z-index.
  • The dialog exposes the ARIA dialog role, aria-modal, and aria-labelledby for the title.

Implementation: a robust pattern

Step 1: portals and the modal root

function Modal({ isOpen, onClose, title, children }) {
  if (!isOpen) return null;
  return ReactDOM.createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        className="modal-dialog"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onClick={e => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.getElementById('modal-root')
  );
}

The portal pulls the modal out of the React tree’s DOM hierarchy, attaching it to a known container. This avoids CSS issues with overflow:hidden ancestors and z-index stacking.

Step 2: ESC handling

useEffect(() => {
  if (!isOpen) return;
  const handler = (e) => {
    if (e.key === 'Escape') onClose();
  };
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
}, [isOpen, onClose]);

For stacked modals, only the topmost should respond to ESC. The cleanest pattern uses a stack in a context provider; the topmost modal subscribes to keyboard events.

Step 3: focus management

const dialogRef = useRef(null);
const previouslyFocusedRef = useRef(null);

useEffect(() => {
  if (!isOpen) return;
  // Save what was focused before
  previouslyFocusedRef.current = document.activeElement;
  // Focus the dialog (or first focusable element inside)
  dialogRef.current?.focus();
  return () => {
    // Restore focus on unmount
    previouslyFocusedRef.current?.focus();
  };
}, [isOpen]);

The dialog itself needs tabIndex={-1} to be focusable.

Step 4: focus trap

When the user presses Tab, focus must cycle within the modal, not escape to background content.

function handleKeyDown(e) {
  if (e.key !== 'Tab') return;
  const focusable = dialogRef.current.querySelectorAll(
    'button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault();
    last.focus();
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault();
    first.focus();
  }
}

This is a hand-rolled focus trap. In production, use the focus-trap library. For the interview, demonstrating that you know the concept and can implement it is the bar.

Step 5: body scroll lock

useEffect(() => {
  if (!isOpen) return;
  const original = document.body.style.overflow;
  document.body.style.overflow = 'hidden';
  return () => {
    document.body.style.overflow = original;
  };
}, [isOpen]);

For stacked modals, the lock should be applied once for any open modal and released only when all are closed. The cleanest implementation uses a counter in a context provider.

Stacking modals

To support multiple modals stacking, hoist the management into a context:

const ModalContext = createContext();

function ModalProvider({ children }) {
  const [stack, setStack] = useState([]);
  const open = (config) => setStack(s => [...s, { id: Date.now(), ...config }]);
  const close = (id) => setStack(s => s.filter(m => m.id !== id));

  return (
    <ModalContext.Provider value={{ open, close }}>
      {children}
      {stack.map((m, i) => (
        <Modal key={m.id} {...m} onClose={() => close(m.id)} isTop={i === stack.length - 1} />
      ))}
    </ModalContext.Provider>
  );
}

The isTop flag tells each modal whether it’s the topmost (only that one responds to ESC, manages focus on close, etc.).

Common pitfalls

  • Forgetting focus restoration breaks the user’s tab order after the modal closes.
  • With no focus trap, Tab escapes to background content and screen readers wander out.
  • ESC closing all modals at once, when it should close only the topmost.
  • Backdrop clicks close the modal even when child clicks bubble up; use stopPropagation on the dialog content.
  • With no body scroll lock, the background scrolls behind the modal.
  • Z-index hell: portals avoid most z-index issues, but stacking modals still need explicit z-index management.
  • Combining aria-modal=true with aria-hidden=false on the background is wrong; aria-modal already implies the background should be inert.

Stretch goals

  • Animated open / close transitions.
  • Confirmation dialog API: const result = await confirm({ title, message }).
  • Different modal sizes / variants (alert, confirm, custom).
  • Modal scrolling when content overflows.
  • Focus return when user closes via Tab+Escape.

Time budget for a 45-minute round

  • 0-5 min: clarify requirements (stacking? animations? confirmation API?).
  • 5-15 min: basic modal with portal, open/close, ESC handling.
  • 15-25 min: focus management and focus trap.
  • 25-35 min: body scroll lock, backdrop click, accessibility attributes.
  • 35-40 min: stacking with context provider.
  • 40-45 min: polish, edge cases.

Frequently Asked Questions

Should I use a library?

For the round, no. In production, Radix UI’s Dialog or React Aria’s Dialog are excellent choices.

Is the focus trap really necessary?

For accessibility yes. WCAG 2.1 requires it for modal dialogs. Senior+ rounds grade for it.

What about the inert attribute for background content?

The HTML inert attribute now exists and can be used for modal backgrounds. Newer pattern; not yet universal but worth knowing for senior+ candidates.

How does this differ from a popover or tooltip?

Modals trap focus and demand interaction; popovers and tooltips don’t. The aria-modal attribute distinguishes them. Different patterns; don’t conflate.

Are nested modals a real-world need?

Sometimes — confirm dialogs spawned from inside a modal, for instance. Many design teams discourage them; the pattern still needs to be supported.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

1972 Soviet postage stamp commemorating the Mars 2 probe

worth a read

Mars For The Rest of Us — a weekly-or-more deep dive on the technical side of Mars exploration: rocket propulsion, microbiology, mission architecture, and everything in between. Written by Maciej Ceglowski.

Read it on Substack
Scroll to Top