feat: let consumers supply the portal container - #118
Merged
Conversation
Every portalled surface — tooltip, popover, select, dialog, sheet, drawer,
dropdown menu — resolved its container with
`document.getElementById('equality-theme-provider-root-portal')`. That works
only when exactly one ThemeProvider exists in the main document, and fails in
two cases we hit in practice:
- Inside a shadow root, `document.getElementById` cannot see the container, so
Radix falls back to `document.body` and every portalled surface renders
outside the shadow boundary — where the design system's styles don't reach,
so they come out completely unstyled.
- With two theme roots on one page, the id resolves to whichever comes first in
document order, so the second root's portals mount inside the first one's
container and inherit its theme.
Resolve the container through React context instead. `ThemeProvider` supplies
the element it already renders, and accepts a `portalContainer` prop to
override it. `PortalContainerProvider` is exported for consumers who need to
supply a container without rendering a ThemeProvider at all.
Backwards compatible: with no provider mounted, `usePortalContainer` falls back
to the original document lookup, so existing consumers are unaffected.
`getThemeProviderRoot` stays exported and is marked deprecated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
Sorry for Claude slop. Needed for high quality implementation of VCO-45 using the shadow DOM in a web component. |
Shrinks99
approved these changes
Jul 28, 2026
Shrinks99
left a comment
Member
There was a problem hiding this comment.
Needed docs updates but I made those.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every portalled surface — tooltip, popover, select, dialog, alert-dialog, sheet, drawer, dropdown-menu — resolves its container with:
That only works when exactly one
ThemeProviderexists in the main document. Two cases where it doesn't:document.getElementByIdcan't see into a shadow tree, so the lookup returnsnull, Radix falls back todocument.body, and every portalled surface renders outside the shadow boundary — where the design system's styles don't reach. They come out completely unstyled.Both are blocking us in
@eqtylab/explorer, which is being turned into an embeddable widget: it needs to render inside a shadow root so a host page's CSS can't reach in, and needs more than one instance per page.Change
Resolve the container through React context instead of a document lookup.
usePortalContainer()reads the nearest provider, falling back to the originaldocument.getElementByIdlookup when none is mounted.ThemeProvidersupplies the element it already renders, and takes an optionalportalContainerprop to override it.PortalContainerProvideris exported for consumers who need to supply a container without rendering aThemeProvider— necessary becauseThemeProvider's root carries a document-uniqueid, so it can't be used more than once per page either.Compatibility
Backwards compatible. With no provider mounted the hook behaves exactly as before, so existing consumers are unaffected whether or not they render a
ThemeProvider.getThemeProviderRootremains exported, now marked@deprecated.ThemeProviderholds its portal element in state rather than a ref, because portalled children read the container during render and need the re-render once the element exists.Verification
pnpm lint— clean (one pre-existing warning indemo/avatar.tsx, untouched)pnpm build— succeeds;PortalContainerProvider,usePortalContainerandPORTAL_ROOT_IDpresent indist/index.d.tsdist/index.jsshows all 8 portals oncontainer: usePortalContainer()and no remaininggetElementByIdcall in that pathPortalContainerProviderlives in its own file to satisfyreact-refresh/only-export-components, which fires when a hook and a component share a module.🤖 Generated with Claude Code