정의
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은 DOM 트리에서 그 scope ancestor를 물리적으로 제거(내용을 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