A composable system for opening stacked, accessible modals from anywhere in the app.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 75-minute target.
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.
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.
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.
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
1import*asDialogfrom"@radix-ui/react-dialog";2import{3 createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef,4}from"react";56type ModalNode={7id: string;8title: string;9kind:"dialog"|"alertdialog";10render:(close:()=>void)=>React.ReactNode;11};12type State={stack:ModalNode[]};13type Action={type:"open"; modal:ModalNode}|{type:"close"; id: string };1415functionreducer(state:State,action:Action):State{16if(action.type==="open")return{stack:[...state.stack, action.modal]};17return{stack: state.stack.filter((m)=> m.id!== action.id)};18}1920constModalCtx= createContext<{21open:(modal:Omit<ModalNode,"id">)=>void;22}|null>(null);2324exportfunctionuseModals(){25const ctx =useContext(ModalCtx);26if(!ctx)thrownewError("useModals must be used inside <ModalProvider>");27return ctx;28}2930exportfunctionModalProvider({ children }:{children:React.ReactNode}){31const[state, dispatch]=useReducer(reducer,{stack:[]});32const openers = useRef<Record<string,HTMLElement|null>>({});3334const open =useCallback((modal:Omit<ModalNode,"id">)=>{35const id = crypto.randomUUID();36 openers.current[id]=document.activeElementasHTMLElement;// remember the trigger37dispatch({type:"open",modal:{...modal, id }});38},[]);3940const close =useCallback((id: string)=>{41dispatch({type:"close", id });42const opener = openers.current[id];43delete openers.current[id];44if(opener?.isConnected)requestAnimationFrame(()=> opener.focus());45},[]);4647// Scroll lock derived purely from 'is the stack non-empty'.48const locked = state.stack.length>0;49useEffect(()=>{50if(!locked)return;51const{ overflow, paddingRight }=document.body.style;52const scrollbar =window.innerWidth-document.documentElement.clientWidth;53document.body.style.overflow="hidden";54document.body.style.paddingRight= scrollbar +"px";// avoid layout shift55return()=>{56document.body.style.overflow= overflow;57document.body.style.paddingRight= paddingRight;58};59},[locked]);6061const value =useMemo(()=>({ open }),[open]);6263return(64<ModalCtx.Provider value={value}>65{children}66{state.stack.map((modal, index)=>{67const isTop = index === state.stack.length-1;68return(69<Dialog.Root70 key={modal.id}71 open72 modal={isTop}73 onOpenChange={(next)=>{if(!next && isTop)close(modal.id);}}74>75<Dialog.Portal>76<Dialog.Overlay/>77<Dialog.Content78 role={modal.kind}79 onEscapeKeyDown={(e)=>{if(!isTop) e.preventDefault();}}80 onInteractOutside={(e)=>{81if(!isTop || modal.kind==="alertdialog") e.preventDefault();82}}83>84<Dialog.Title>{modal.title}</Dialog.Title>85{modal.render(()=>close(modal.id))}86</Dialog.Content>87</Dialog.Portal>88</Dialog.Root>89);90})}91</ModalCtx.Provider>92);93}
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.