skip to content

A design system's Modal, Tooltip, Select and Toast components all call createPortal(content, document.body). As the library owner, what would you standardize about the portal target, and what does portalling cost the consumers of those components?

level: principalimportance: nice to knowfreq 20%

answer

  1. container from context, not hardcoded
  2. theming lives in the DOM you left
  3. one shared root, one stacking order
  4. reading order stops matching what you see
  5. not every overlay needs to escape

basics

~20 s

Standardize on one configurable portal root that lives inside the app's theming wrapper, injected through context so consumers can retarget it. The costs are lost CSS inheritance and scoping, global stacking order between overlays, reading order and focus that no longer match the visual layout, and no server-rendered markup.

solid answer

~50 s

I would stop hardcoding `document.body` and take the container from context, defaulting to a single library-owned root element mounted inside the app's theme wrapper. That gives three things at once: inherited CSS custom properties and theme classes keep working, every overlay in the app shares one predictable stacking order instead of competing `z-index` values, and consumers embedding the library inside a shadow root, an iframe, a modal of their own, or a test container can retarget it without forking a component. Then I would document the costs honestly — descendant selectors break, DOM reading order stops matching visual order so `aria` relationships and focus management become the component's job, tooltips anchored to scrollable content need repositioning logic, and portalled content is not server-rendered. Finally I would not portal by default: only components that genuinely have to escape clipping get one.

go deeper

for a junior

Know the practical takeaway: a portalled dialog can lose the app's theme styling, because CSS inheritance follows the DOM and the dialog no longer sits under the themed wrapper.

for a middle

Be able to describe making the container configurable through context with a shared default root, and name the concrete losses: descendant selectors, inherited custom properties, and positioning inside scroll containers.

for a senior

Demonstrate accessibility ownership — focus move and restore, focus trapping, Escape, aria wiring — plus the repositioning cost for anchored overlays and the fact that portal content is not server-rendered.

for a principal

Set the policy: which components may portal at all, one context-resolved root inside the theme wrapper, layer values as tokens, escape hatches for shadow roots and tests, and a per-component review of whether the platform top layer replaces portals entirely.

## Why `document.body` as a hardcoded target is the wrong default It works in the demo app and fails in every embedded one. Hardcoding the container means a consumer can never place overlays anywhere else, and several ordinary situations require exactly that: rendering the library inside a shadow root or an iframe, mounting a widget into a host page you do not control, rendering inside a full-screen element (which paints in the browser's top layer, above anything in `body`), or scoping overlays to a test container so parallel tests do not see each other's dialogs. The standard shape is a container resolved from context, with a sensible default: ```jsx const PortalContainerContext = createContext(null); function useOverlayContainer() { return useContext(PortalContainerContext) ?? document.body; } ``` Each overlay calls `createPortal(content, useOverlayContainer())`. One provider at the app root retargets everything; a nested provider retargets a subtree. ## Put the root inside the theme wrapper CSS inheritance follows the DOM. Portalling to `document.body` therefore drops every inherited value the app set on a wrapper: font stack, text color, and — the one that bites hardest — CSS custom properties. A `--surface` token defined on `.app.theme-dark` simply does not reach an element attached to `body`, so the portalled dialog renders with fallback colors while the rest of the app is dark. The same applies to a dark-mode class, a locale-driven `direction: rtl`, and any container-query or font-size context. Mounting the shared overlay root *inside* the theming wrapper rather than as a sibling of it fixes all of these at once, provided that wrapper is not itself a clipping or stacking ancestor. That proviso is the trade: the root must be high enough to escape cards and scroll containers, low enough to inherit theming. When it cannot be both, the alternative is to re-apply the theme class or copy the token values onto the portal root — workable, but a maintenance obligation. ## Stacking becomes a global concern Once every overlay is a sibling in one container, their relative order is decided by DOM order among equal `z-index` values, and by explicit `z-index` otherwise. That is an improvement — it is now one ordering problem rather than N unrelated ones — but it must be designed: a toast should sit above a modal, a tooltip above both, a dropdown inside a modal above that modal. Publish those layer values as tokens rather than letting each component pick a number, and the `z-index` arms race stops. ## What consumers lose, and must be told **CSS scoping.** Selectors written as `.card .tooltip` stop matching. Overlay styling has to be self-contained. **Reading and focus order.** DOM order drives screen readers and Tab order. A portalled dialog appended to a root at the end of the document is announced far from the control that opened it, and Tab from the trigger goes to the *next page control*, not into the dialog. The library must own focus movement on open, focus restoration on close, focus trapping for modals, Escape handling, and `aria-modal`/`role="dialog"` or `aria-controls` wiring. `<dialog>` opened with `showModal()` gives some of this for free and is worth evaluating per component. **Positioning.** A tooltip anchored to an element inside a scroll container no longer moves with it, because it is no longer a descendant. Portalled positioning needs measurement on scroll and resize, which is a real runtime cost and the reason positioning libraries exist. **Server rendering.** Portal content is not produced by React's server renderers, so overlay markup only appears after the client renders. For a modal that is fine; for something that should be in the initial HTML, a portal is the wrong tool. **Testing and debugging.** Queries scoped to a rendered container miss portalled output, and "why is this node at the end of body?" costs newcomers time. Both are documentation problems more than technical ones. ## The policy I would write down Portal only what must escape clipping or a stacking context — a select menu and a modal, yes; an inline validation message, no. Resolve the container from context with a single default root. Publish layer tokens. Make focus, Escape and aria non-optional parts of each overlay rather than consumer responsibilities. Reassess per component whether the platform's top layer — `<dialog>` with `showModal()`, or the HTML `popover` attribute — removes the need for a portal at all, since those escape clipping without moving the element anywhere.

  • Why does a dark theme sometimes fail to apply to a portalled dialog?
    Because theming usually rides on DOM inheritance — a class on an app wrapper plus CSS custom properties defined there. A portal to `document.body` puts the dialog outside that wrapper, so it inherits nothing and falls back to defaults. Mounting the shared overlay root inside the themed wrapper fixes it; otherwise you must re-apply the theme class or duplicate the token values on the portal root.
  • How do you keep overlay stacking predictable once everything is portalled into one root?
    Publish layer values as design tokens — toast above modal, tooltip above both — and forbid per-component `z-index` literals. Within a single container, DOM order also breaks ties between equal values, so mount order matters and should be deliberate. The win of one shared root is that stacking becomes a single designed ordering rather than an arms race between unrelated components.
  • When would you drop the portal in favour of a platform primitive?
    When the component maps onto the browser's top layer: `<dialog>` opened with `showModal()` for modal dialogs, or the HTML `popover` attribute for lightweight popovers. Both escape clipping and stacking contexts without moving the element in the DOM, so CSS inheritance, reading order and positioning context are preserved, and some focus and dismissal behaviour comes for free. Support and styling constraints decide whether that is available to you.
  • Should the portal container be configurable per component or per app?
    Per app by default, per subtree when needed — one context provider at the root, overridable lower down. Per-component props invite inconsistency and defeat the shared stacking order. The escape hatch matters for shadow roots, iframes, full-screen elements and test isolation, so it should exist; it just should not be the ordinary path.

saying these in an interview costs you the question

  • Says portalling to document.body has no downsides
  • Ignores focus order and treats it as the consumer's problem
  • Assumes theme styles follow the component into the portal
  • Lets every overlay pick its own z-index number
  • Portals every overlay, whether or not it is clipped

context