定義
Notionのように、カードを押すと文書がポップアップ(モーダル)で開き、その中のリンクをまた押しても新しいポップアップを重ねず、同じポップアップの中身だけが次の文書に切り替わりながら深さが積み上がる、というナビゲーションを作りたいときに使うパターンである。「戻る」を押すと前の文書に戻る。つまり、ポップアップの外殻は1つに固定し、その中に入った文書だけを差し替える。
正確には、Nested modal document navigation は overlay dialog(画面の上に浮いて背後の背景を覆うポップアップ窓)を1つ維持したまま、内部で documentId(どの文書を表示するかを指す識別子)だけを変えながら深さを積み上げるUXパターンである。gallery card(一覧のカード)→peek(カードを押して少し覗き見るプレビューのポップアップ)→related page(その文書が参照する関連文書)が同じ shell(ポップアップ外殻コンポーネント)の連続性を保つ。ここで、開いてきた文書を順に積んだものをスタックといい、新しい文書に入ることをpush、戻ることをpopという。
なぜ必要か
文書Aをポップアップで開き、その中のリンクで文書B、さらに文書Cへ潜っていく流れを素朴に作ると、しばしば「リンクごとに新しいポップアップ/新しいルート(URLパス)を開く」ことになる。すると、ポップアップが何重にも重なりながら、次の問題が一度に重なって噴出する。
- focus trapの入れ子: focus trapは、ポップアップが開いている間キーボードフォーカス(Tab移動)がポップアップの外に漏れないよう閉じ込めるアクセシビリティの仕組みである。ポップアップが何重にもなると、どの層にフォーカスを閉じ込めるかがこじれる。
- scroll lockの入れ子: scroll lockは、ポップアップが浮いているときに背後の背景ページがスクロールしないよう止める処理である。何重にもなると、ロック・解除のタイミングがずれる。
- アニメーションの入れ子とspatial context(空間的文脈、「自分は今どこにいて、どうやってここに来たか」の感覚)の断絶: 毎回新しいポップアップが上から飛び出すと、ユーザーは階層を見失う。
Notion型のpeekはこの問題を、フレーム(ポップアップ外殻)は固定、中身だけpushで解く。外殻が1つだけなので、focus trap・scroll lock・アニメーションはちょうど1回だけかかり、ユーザーは同じ窓の中で前後に移動する一貫した感覚を得る。
動作原理
- Modalコンポーネントはmount(画面に付いた状態)を維持し、中で
activeDocumentIdstateだけを変える。外殻を作り直さないので、focus trap・scroll lock・開きアニメーションは1回だけ実行される。 - スタック・breadcrumb(現在地までの経路表示)・URL同期(任意)はnavigatorが所有する。 すなわち「これまでどの文書を経由してきたか」の一覧は、ポップアップ自身ではなく別のnavigator層が持ってこそ、戻る・経路表示が一箇所で管理される。
- readOnly(読み取り専用)からeditable(編集可能)へ昇格(promotion)されるときは、
onContentChangeコールバックで変更を上位にpersist(保存)する。プレビューで入ってきて編集を始めたとき、その編集が失われないようにする地点である。 - 非同期resolve(文書データをサーバー・キャッシュから読み込んで確定する過程)中の中間フレーム遮断: 次の文書
newIdを要求してデータが到着する前まで、前の文書oldDocと混ざった中途半端な中間画面が一瞬見えないようレンダーを止める。
実務適用
- refreshモードでpeekを開く: 普段はキャッシュにあればそのまま使うcache-firstが速いが、「再読み込み」の意図で開くときはキャッシュを飛ばしてforce network(必ずサーバーから新しく取得する)で最新版を保証する。
- Playwright(ブラウザ自動化E2Eテストツール)のdrag失敗はsimulation baselineを分離する。 ドラッグのような物理シミュレーションは実環境とテスト環境で結果が変わりやすいので、テスト基準線を別に置いて誤検出を減らす。
- RELATED(関連文書)リンクは新しいタブではなくin-modal pushで処理する。同じポップアップの中で次の文書へつながってこそスタックの連続性が保たれる。
トレードオフ
- スタック深さの上限(depth cap)とモバイルの戻るジェスチャー方針が必要である。 無限に潜らせると、ユーザーは何重に入ったか見失い、モバイルではスワイプの戻るとポップアップのpopが衝突する。
- popstate(ブラウザの戻る/進むボタンが押されたときに発生するイベント)/backボタンはview transition(画面遷移アニメーションAPI)と相性が悪い。 ブラウザ履歴に依存すると遷移アニメーションとずれるので、必要ならURLを明示的にpushしてスタックと履歴を自分で合わせる。
使ってはいけない場合
- カードグリッドが浅く(shallow)related depthがなく、「カード → 文書1つ」で終わるなら、スタックを積むことがないので単なるsingle-level modal(1層のポップアップ)で十分である。わざわざスタック・navigatorを作る必要はない。
よくある間違い
useResolvedDocument(現在のdocumentIdで文書データを確定してくれるフック)がstale(古い値)になる: 次の文書へpushしたのにrender時点のresetを漏らし、前の文書データがそのまま残る。documentIdが変わったら、レンダー時点で派生状態を初期化しなければならない。- reopen(再度開く)時のロールバック: 編集後に閉じてから再度開いたとき、cache-firstが古いキャッシュを見せて編集が消えたように見える。再オープン経路のキャッシュ鮮度方針を明確にする。
関連概念
- hierarchical-document-navigation — 文書階層をたどって移動する上位のナビゲーションモデル
- fixed-position-floating-ui-scroll — ポップアップ内のフローティングUIがスクロールとずれないようにする位置処理