Skip to content
Advanced75 min build target7 min guide

Modal manager

A composable system for opening stacked, accessible modals from anywhere in the app.

Modal manager interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 75-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Open a modal imperatively or declaratively from any component, without each caller reimplementing overlay/focus logic.
  • Support stacking: opening a second modal on top of a first, and closing back down the stack correctly.
  • Trap focus within the topmost modal; restore focus to the trigger element when it closes.
  • Close on Escape and on overlay click, with an option to disable either per-modal.
  • Prevent background scroll while any modal is open.

EDGE CASES

  • Closing the bottom modal in a stack of two must not also close the top one, and vice versa.
  • A modal opened from within another modal's content must trap focus to the new top modal, not the one beneath it.
  • Rapid open/close calls (e.g. double-click a trigger) shouldn't leave duplicate overlays or a scroll-lock that never releases.
  • Unmounting a component while its modal is open should close the modal cleanly, not leave an orphaned overlay.

ACCESSIBILITY

  • Each modal uses role="dialog" (or "alertdialog" for critical confirmations) with aria-modal="true" and an accessible name via aria-labelledby.
  • Focus moves into the modal on open (typically to the first focusable element or a designated initial-focus target) and is trapped with Tab/Shift+Tab cycling within it.
  • Focus returns to the exact element that triggered the modal when it closes.

SUGGESTED APPROACH

  • Maintain a stack (array) of open modal descriptors in a context/store, not a single boolean — the top of the stack is what's interactive.
  • Implement a single focus-trap utility used by every modal instance, keyed to whichever modal is currently on top of the stack.
  • Use a ref-counted scroll lock (increment on open, decrement on close) so nested modals don't fight over restoring scroll too early.
  • Portal each modal to a dedicated root element to keep stacking order and CSS containment predictable.

EVALUATION RUBRIC

  • Stacking behaves correctly: only the top modal traps focus and responds to Escape/overlay click.
  • Focus is trapped correctly and restored to the trigger on close, including for nested modals.
  • Scroll lock is reference-counted correctly across nested opens/closes.
  • No orphaned overlays or listeners after rapid open/close or unmount.

01Understand the product before coding

Learning goals

  • Open a modal imperatively or declaratively from any component, without each caller reimplementing overlay/focus logic.
  • Support stacking: opening a second modal on top of a first, and closing back down the stack correctly.
  • Trap focus within the topmost modal; restore focus to the trigger element when it closes.
  • Close on Escape and on overlay click, with an option to disable either per-modal.

Decisions to state aloud

  • Closing the bottom modal in a stack of two must not also close the top one, and vice versa.
  • A modal opened from within another modal's content must trap focus to the new top modal, not the one beneath it.
  • Rapid open/close calls (e.g. double-click a trigger) shouldn't leave duplicate overlays or a scroll-lock that never releases.
  • Unmounting a component while its modal is open should close the modal cleanly, not leave an orphaned overlay.

02State model and invariants

Model modals as a stack of descriptors with stable ids, content, dismissal rules, and the element that opened each one. Only the top descriptor is interactive. Scroll lock derives from stack length, preventing conflicting booleans.

TypeScript
type Modal = { id: string; kind: 'dialog' | 'alertdialog';  title: string; dismissOnEscape: boolean; dismissOnOutside: boolean;  restoreFocusTo: HTMLElement | null; content: React.ReactNode; };type ModalState = { stack: Modal[] };const top = state.stack.at(-1);

03Component architecture

  1. 01ModalProvider owns stack commands.
  2. 02ModalViewport portals stack layers once.
  3. 03Radix Dialog supplies focus containment and dismissal primitives per layer.
  4. 04ScrollLock derives from nonempty stack and preserves scrollbar compensation.
  5. 05Modal-specific bodies remain ordinary controlled components.

04Reference implementation walkthrough

Step 1

Push and remove by stable id

Open captures the active trigger. Close removes the requested descriptor without assuming it is the only modal. Only the top layer handles Escape and outside interaction.

TypeScript
function reducer(state: ModalState, action: Action): ModalState {  if (action.type === 'open') return { stack: [...state.stack, action.modal] };  if (action.type === 'close') return {    stack: state.stack.filter((modal) => modal.id !== action.id)  };  return state;}

Step 2

Delegate focus mechanics to Radix

Use Dialog Title and Description, allow the top layer to be modal, and customize initial focus only for a reason. Prevent accidental auto-focus when destructive confirmations should begin on a safe action.

TSX
<Dialog.Root open onOpenChange={(open) => !open && close(id)}>  <Dialog.Portal>    <Dialog.Overlay />    <Dialog.Content aria-describedby={descriptionId}>      <Dialog.Title>{title}</Dialog.Title>      {content}    </Dialog.Content>  </Dialog.Portal></Dialog.Root>

Step 3

Restore the correct owner

Each descriptor stores its trigger. Closing the top restores that trigger if connected; closing a lower descriptor while another remains must not pull focus behind the active modal.

Step 4

Reference-count global effects

Apply body scroll lock when the stack changes from zero to one and restore only at zero. Preserve original styles and compensate scrollbar width to avoid layout shift.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a manager that can stack multiple dialogs, where only the top one reacts to Escape and outside clicks, focus returns to whatever opened each dialog, and the page behind stays locked from scrolling. The one big idea: model open modals as a stack (an array), not a boolean. The array knows how many layers there are, which is on top, and which element opened each one.

ModalProvider.tsx
import * as Dialog from "@radix-ui/react-dialog";import {  createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef,} from "react";
type ModalNode = {  id: string;  title: string;  kind: "dialog" | "alertdialog";  render: (close: () => void) => React.ReactNode;};type State = { stack: ModalNode[] };type Action = { type: "open"; modal: ModalNode } | { type: "close"; id: string };
function reducer(state: State, action: Action): State {  if (action.type === "open") return { stack: [...state.stack, action.modal] };  return { stack: state.stack.filter((m) => m.id !== action.id) };}
const ModalCtx = createContext<{  open: (modal: Omit<ModalNode, "id">) => void;} | null>(null);
export function useModals() {  const ctx = useContext(ModalCtx);  if (!ctx) throw new Error("useModals must be used inside <ModalProvider>");  return ctx;}
export function ModalProvider({ children }: { children: React.ReactNode }) {  const [state, dispatch] = useReducer(reducer, { stack: [] });  const openers = useRef<Record<string, HTMLElement | null>>({});
  const open = useCallback((modal: Omit<ModalNode, "id">) => {    const id = crypto.randomUUID();    openers.current[id] = document.activeElement as HTMLElement; // remember the trigger    dispatch({ type: "open", modal: { ...modal, id } });  }, []);
  const close = useCallback((id: string) => {    dispatch({ type: "close", id });    const opener = openers.current[id];    delete openers.current[id];    if (opener?.isConnected) requestAnimationFrame(() => opener.focus());  }, []);
  // Scroll lock derived purely from 'is the stack non-empty'.  const locked = state.stack.length > 0;  useEffect(() => {    if (!locked) return;    const { overflow, paddingRight } = document.body.style;    const scrollbar = window.innerWidth - document.documentElement.clientWidth;    document.body.style.overflow = "hidden";    document.body.style.paddingRight = scrollbar + "px"; // avoid layout shift    return () => {      document.body.style.overflow = overflow;      document.body.style.paddingRight = paddingRight;    };  }, [locked]);
  const value = useMemo(() => ({ open }), [open]);
  return (    <ModalCtx.Provider value={value}>      {children}      {state.stack.map((modal, index) => {        const isTop = index === state.stack.length - 1;        return (          <Dialog.Root            key={modal.id}            open            modal={isTop}            onOpenChange={(next) => { if (!next && isTop) close(modal.id); }}          >            <Dialog.Portal>              <Dialog.Overlay />              <Dialog.Content                role={modal.kind}                onEscapeKeyDown={(e) => { if (!isTop) e.preventDefault(); }}                onInteractOutside={(e) => {                  if (!isTop || modal.kind === "alertdialog") e.preventDefault();                }}              >                <Dialog.Title>{modal.title}</Dialog.Title>                {modal.render(() => close(modal.id))}              </Dialog.Content>            </Dialog.Portal>          </Dialog.Root>        );      })}    </ModalCtx.Provider>  );}

How each part works

A stack, not a boolean

state.stack is an array of modal descriptors. A single isOpen boolean cannot say 'two dialogs are open, this one is on top, and each was opened by a different button'. The array answers all of that: length is the layer count, the last item is the top.

Each open remembers its trigger

In open(), before anything renders, we read document.activeElement — the element that had focus, which is the button the user clicked — and store it in openers keyed by the new modal id. This is captured per modal, so a stack of three dialogs has three saved triggers.

Close restores focus to the right element

close() removes the descriptor, then looks up its saved trigger and, only if that element is still in the document (isConnected), focuses it on the next frame. If the trigger was unmounted while the modal was open, we skip it rather than call focus on a detached node.

Only the top layer is interactive

isTop is index === stack.length - 1. The top Dialog is modal (it traps focus and marks the rest inert). Escape and outside-click handlers call preventDefault when !isTop, so a key or click cannot close a buried layer or leak through to the one beneath.

alertdialog resists casual dismissal

role is set from modal.kind. For an alertdialog — used for destructive confirmations — onInteractOutside always preventDefaults, so clicking the backdrop does not dismiss it; the user must choose an explicit button.

Scroll lock is derived, not a separate flag

locked is just state.stack.length > 0. The effect adds overflow: hidden to the body when locked becomes true and restores the exact previous inline styles when it becomes false. It also adds padding equal to the scrollbar width so the page does not jump sideways when the scrollbar disappears.

Radix owns the hard focus mechanics

Focus trapping, initial focus, returning focus to the portal boundary, and marking background content inert are all handled by @radix-ui/react-dialog. The manager only owns the stack, the per-modal trigger memory, the layer policy, and the scroll lock.

Why this is correct

  • Modeling open modals as a stack makes layer count, topmost ownership, and per-modal trigger memory explicit — a boolean cannot.
  • Capture the opening element at open time, per modal, and restore focus to it only if it is still connected to the document.
  • Escape and outside-click must be handled by the top layer only; lower layers preventDefault so events do not cascade.
  • Scroll lock is a derived value (stack non-empty), and it must save and restore the exact prior body styles plus scrollbar-width padding.
  • Delegate focus trapping and inertness to a tested dialog primitive; the manager owns policy, not focus plumbing.

05Testing strategy

Critical behavior

  • Stacking behaves correctly: only the top modal traps focus and responds to Escape/overlay click.
  • Focus is trapped correctly and restored to the trigger on close, including for nested modals.
  • Scroll lock is reference-counted correctly across nested opens/closes.
  • No orphaned overlays or listeners after rapid open/close or unmount.

Failure and boundary cases

  • Closing the bottom modal in a stack of two must not also close the top one, and vice versa.
  • A modal opened from within another modal's content must trap focus to the new top modal, not the one beneath it.
  • Rapid open/close calls (e.g. double-click a trigger) shouldn't leave duplicate overlays or a scroll-lock that never releases.
  • Unmounting a component while its modal is open should close the modal cleanly, not leave an orphaned overlay.

Accessibility

  • Each modal uses role="dialog" (or "alertdialog" for critical confirmations) with aria-modal="true" and an accessible name via aria-labelledby.
  • Focus moves into the modal on open (typically to the first focusable element or a designated initial-focus target) and is trapped with Tab/Shift+Tab cycling within it.
  • Focus returns to the exact element that triggered the modal when it closes.

06Performance and production hardening

  • Render one provider and portal viewport.
  • Avoid serializing large content into a global store when a typed modal registry can store props.
  • Remove global listeners with the stack lifecycle.
  • Keep exit animation short and bypass it for reduced motion.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

A boolean cannot identify multiple layers, their triggers, or dismissal policies. A stack makes topmost ownership and restoration explicit.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 75 minutes, then self-review against the rubric before moving on.

Back to all briefs →