Definition
This is the pattern to use when, Notion-style, tapping a card opens a document as a popup (modal), and tapping a link inside it does not stack a new popup on top — instead only the contents of the same popup change to the next document while depth accumulates. Pressing "back" returns to the previous document. In other words, the popup shell stays fixed as one, and only the document inside it is swapped out.
More precisely, Nested modal document navigation is a UX pattern that keeps a single overlay dialog (a popup window that floats over the screen and covers the background behind it) while changing only the documentId (the identifier pointing to which document to show) inside it, accumulating depth. A gallery card (a card in a list) → peek (the preview popup you open by tapping a card to glance in) → related page (a related document that document references) all maintain continuity within the same shell (the popup-shell component). Here, the documents you've opened stacked in order is called the stack, entering a new document is push, and going back is pop.
Why it's needed
When you naively build a flow of opening document A as a popup, then drilling into document B via a link inside it, then into document C, it's common to "open a new popup / new route (URL path) for each link." Then the popups stack in multiple layers, and the following problems compound at once.
- Nested focus trap: A focus trap is an accessibility device that keeps keyboard focus (Tab movement) from leaking outside the popup while it's open. With multiple layered popups, it gets tangled which layer to trap focus in.
- Nested scroll lock: Scroll lock is the handling that prevents the background page from scrolling while a popup floats. With multiple layers, the lock/unlock timing goes out of sync.
- Nested animation and a break in spatial context (the sense of "where I am now and how I got here"): if a new popup pops out from above every time, the user loses the hierarchy.
Notion-style peek solves this by keeping the frame (popup shell) fixed and pushing only the content. Since there's only one shell, focus trap, scroll lock, and animation fire exactly once, and the user gets a consistent sense of moving back and forth within the same window.
How it works
- The Modal component stays mounted (attached to the screen), and only the
activeDocumentIdstate changes inside. Since the shell isn't rebuilt, focus trap, scroll lock, and the open animation run only once. - The stack, breadcrumb (showing the path up to the current location), and URL sync (optional) are owned by the navigator. That is, the list of "which documents you've passed through so far" should be held not by the popup itself but by a separate navigator layer, so that back navigation and path display are managed in one place.
- When promoting from readOnly to editable, changes are persisted to the parent via the
onContentChangecallback. This is the point that ensures the edit isn't lost when you enter as a preview and then start editing. - Blocking intermediate frames during async resolve (the process of loading and confirming document data from server/cache): until the requested next document
newIddata arrives, rendering is blocked so that an awkward intermediate screen mixed with the previous documentoldDocdoesn't briefly flash.
Practical application
- Opening a peek in refresh mode: normally cache-first (use it immediately if it's in cache) is fast, but when opening with "refresh" intent, skip the cache and force network (always fetch fresh from the server) to guarantee the latest copy.
- Playwright (a browser-automation E2E testing tool) drag failures: separate the simulation baseline. Physical simulations like drag easily produce different results between real and test environments, so keep a separate test baseline to reduce false positives.
- RELATED (related document) links are handled as in-modal push, not a new tab. It must continue to the next document within the same popup to maintain stack continuity.
Trade-offs
- A stack depth cap and a mobile back-gesture policy are needed. If you let it drill infinitely, the user loses track of how many layers deep they are, and on mobile the swipe-back gesture collides with popup pop.
- popstate (the event fired when the browser's back/forward button is pressed) / back button plays poorly with view transition (the screen-transition animation API). Relying on browser history clashes with the transition animation, so if needed, explicitly push the URL to align the stack and history yourself.
When not to use it
- If the card grid is shallow and there's no related depth, so it ends at "card → one document," there's nothing to stack, and a single-level modal (a one-layer popup) is enough. There's no need to build a stack/navigator at all.
Common mistakes
useResolvedDocument(the hook that resolves document data for the current documentId) goes stale: after pushing to the next document, you forgot to reset at render time, so the previous document data lingers. When the documentId changes, derived state must be reset at render time.- Rollback on reopen: after editing, closing, then reopening, cache-first shows the old cache, making it look like the edit disappeared. Make the cache-freshness policy of the reopen path explicit.
Related concepts
- hierarchical-document-navigation — a higher-level navigation model that moves along the document hierarchy
- fixed-position-floating-ui-scroll — position handling so floating UI inside the popup doesn't drift with scroll