定義
Radix UIで作ったポップオーバー・ドロップダウンメニューが、特定の領域の中だけで定義したスタイルを受け取れず「なぜスタイルが効かない?」となるとき、その原因を理解して直したいときに見る文書である。
正確には、Radix Portalとscoped CSSの問題は、Radix UI Popover/DropdownMenuなどがデフォルトでReact Portal(コンポーネントを本来のDOM位置ではなく別の場所、通常document.bodyの下にレンダーするReactの機能)でdocument.bodyにcontentをレンダーするとき、[data-editor-scope] .menu-itemのようなancestor(祖先要素)ベースのscoped CSS(特定の親の中だけで適用されるよう範囲を狭めたスタイル)が適用されない現象と、Portalを切るかcontainerをscope内部に置いて解決するパターンを指す。
なぜ必要か
エディタ・デザインシステムは、グローバル汚染(スタイルがアプリ全体に漏れ出すこと)を防ぐため、scope attribute(例: data-editor-scope)の下だけでトークン・コンポーネントのスタイルを定義する。Portalはそのscope ancestorをDOMツリーから物理的に除去(中身をbody直下へ移す)するので、popoverがスタイルなし(unstyled)・誤ったz-index(要素が前後に重なる順序を定める値)・誤ったタイポグラフィで見えたり、テストでgetByRoleが失敗する。
動作原理
- Triggerはscope内部に残る。
- Default:
Popover.Portal→ contentがbodyの直系子孫。 - Scoped CSS
[data-scope] .popoverはcontentにマッチしない。 - Fix A: Portal無効 — contentをtrigger近くにinlineレンダー。
- Fix B:
Popover.Portal container={scopeRef.current}— portal targetをscope rootに。 - Fix C: design tokensをCSS variablesとしてscope rootにduplicate(コスト↑)。
実務適用
Radix Popover (inline):
テスト:
小さなインラインtoolbar menu・image layout popoverはinlineが適し、fullscreen modal・toastはbody portalを維持する方がよい場合がある。overflow: hiddenのancestorがinline popoverをclipするかvisuallyに確認する。
トレードオフ
- inline: scope CSS・testingが単純、overflow clipping(親の
overflow: hiddenに切られる)・stacking(要素の重なり順)に注意。 - portal: clippingをescape、scope/styleが分離。
- container portal: 折衷、ref lifecycleの管理が必要。
使ってはいけないcase
- すでに
:rootCSS variables + グローバルpopoverスタイルで統一されたアプリ — scopeの問題がない場合がある。 - popoverがviewport全体を覆うmodal級のUI — inlineは不適。
よくある間違い
- Portal default + scoped CSSを混用したあと「Radix bug」と誤認する。
- closedなpopover内部のbuttonをquery — trigger clickの漏れ。
- z-index tokenをscope外だけで定義 — inline popoverがtoolbarの下に敷かれる。
関連概念
- css-cascade-layers-theming — cascade・token layering
- contenteditable-keyboard-history-delegation — toolbar focus・keyboard