Definition
This is the doc to read when a popover / dropdown menu built with Radix UI fails to receive styles you defined to apply only within a certain region — a "why aren't the styles working?" situation — and you want to understand the cause and fix it.
More precisely, the Radix Portal and scoped CSS problem refers to the phenomenon where Radix UI Popover/DropdownMenu and the like, by default rendering content into document.body via a React Portal (a React feature that renders a component somewhere other than its original DOM position, usually under document.body), causes ancestor-based scoped CSS like [data-editor-scope] .menu-item (styles narrowed to apply only inside a specific parent) not to apply — and the pattern of solving it by disabling the Portal or placing the container inside the scope.
Why it's needed
To prevent global pollution (styles leaking out into the whole app), editors and design systems define token/component styles only under a scope attribute (e.g. data-editor-scope). The Portal physically removes that scope ancestor from the DOM tree (moving the content right under body), so the popover appears unstyled, with wrong z-index (the value that sets the front/back stacking order of elements) or wrong typography, or getByRole fails in tests.
How it works
- The Trigger stays inside the scope.
- Default:
Popover.Portal→ content becomes a direct child of body. - Scoped CSS
[data-scope] .popoverdoesn't match the content. - Fix A: disable the Portal — render content inline near the trigger.
- Fix B:
Popover.Portal container={scopeRef.current}— set the portal target to the scope root. - Fix C: duplicate design tokens as CSS variables on the scope root (higher cost).
Practical application
Radix Popover (inline):
Test:
A small inline toolbar menu / image-layout popover suits inline, while a fullscreen modal / toast may be better kept as a body portal. Visually verify whether an overflow: hidden ancestor clips the inline popover.
Trade-offs
- inline: scope CSS / testing simple; beware overflow clipping (clipped by a parent's
overflow: hidden) and stacking (element overlap order). - portal: escapes clipping; scope/style separated.
- container portal: a compromise; requires managing the ref lifecycle.
When not to use it
- An app already unified with
:rootCSS variables + global popover styles — there may be no scope issue. - A modal-grade UI where the popover covers the whole viewport — inline is unsuitable.
Common mistakes
- Mixing Portal default + scoped CSS, then mistaking it for a "Radix bug."
- Querying a button inside a closed popover — missing the trigger click.
- Defining the z-index token only outside the scope — the inline popover gets buried under the toolbar.
Related concepts
- css-cascade-layers-theming — cascade / token layering
- contenteditable-keyboard-history-delegation — toolbar focus / keyboard