Acronis UIKit

Shadow DOM Integration

How to render @acronis-platform/ui-react inside a shadow-DOM micro-frontend with fully styled portaled components and zero host style leakage.

When @acronis-platform/ui-react runs inside a shadow-DOM micro-frontend (MFE), portaled components — Popover, DropdownMenu, Tooltip, Dialog, Sheet, Select, Combobox, and Toaster — mount their popups into document.body by default. This means the popup escapes the shadow boundary and loses access to the adopted stylesheets, rendering unstyled.

PortalContainerProvider solves this by redirecting all portals into a container inside the shadow root.

Quick setup

If your host already renders the app inside its shadow root, all you need is a container element to point the provider at:

import { useState } from 'react';
import { PortalContainerProvider } from '@acronis-platform/ui-react';

function ShadowMFE() {
  // Hold the mount element in state, not a ref: the provider reads `container`
  // during render, and populating a ref doesn't trigger one. With a ref, the
  // provider keeps the `null` it saw on the first render — and a `null`
  // container renders no popup at all, so overlays silently never open.
  const [mount, setMount] = useState<HTMLElement | null>(null);

  return (
    <div ref={setMount}>
      <PortalContainerProvider container={mount}>
        <App />
      </PortalContainerProvider>
    </div>
  );
}

That's it. Every portaling component automatically picks up the container from context. No need to pass portalContainer to each component individually.

Passing the state setter straight to ref is the shortest correct form — React calls it with the element on mount (and with null on unmount), so the provider re-renders exactly once with the real container.

If your app also creates the shadow root, attach it and render into it with createPortal first — see ShadowDemo.tsx, which is what powers the live previews on these component pages — then adopt the stylesheet as shown in Adopting styles.

How it works

  1. PortalContainerProvider sets a React context with the target DOM element.
  2. Each portaling component calls usePortalContainer() internally to read the context.
  3. The component passes the resolved container to the Base UI Portal primitive, which creates a React portal into that element instead of document.body.
  4. An explicit portalContainer prop on any individual component always takes precedence over the context — useful for edge cases where one popup needs a different container.

Affected components

All portaling components respect PortalContainerProvider:

ComponentPortal target
PopoverContentPositioner + popup
DropdownMenuContentPositioner + popup
TooltipContentPositioner + popup
DialogBackdrop + popup
SheetContentBackdrop + popup
SelectContent / InputSelectContentPositioner + popup
ComboboxContentPositioner + popup
ToasterViewport + toasts

Adopting styles

ui-react's CSS is authored for :root, :host, so the same stylesheet works inside a shadow root. The recommended approach:

// Fetch or import ui-react's compiled CSS
import cssText from '@acronis-platform/ui-react/styles?raw';

// Build a Constructable Stylesheet and adopt it
const sheet = new CSSStyleSheet();
sheet.replaceSync(cssText);
shadowRoot.adoptedStyleSheets = [sheet];

This keeps ui-react's Tailwind Preflight and design tokens inside the shadow boundary — they never touch the host document.

Theming inside the shadow root

ui-react uses light-dark() with the [data-theme] attribute. Set it on your mount element:

<div data-theme="light">
  <PortalContainerProvider container={mountEl}>
    <App />
  </PortalContainerProvider>
</div>

To follow the host page's theme, observe document.documentElement for data-theme or class changes and mirror them onto your shadow mount.

Overriding per-component

If a specific component needs to portal elsewhere (e.g. a full-screen dialog that must overlay the entire viewport), pass portalContainer directly:

<PortalContainerProvider container={shadowMount}>
  {/* This popover portals into the shadow root (from context) */}
  <Popover>
    <PopoverTrigger>Open</PopoverTrigger>
    <PopoverContent>Inside shadow</PopoverContent>
  </Popover>

  {/* This dialog portals to document.body (explicit override) */}
  <Dialog portalContainer={document.body} open>
    Full-screen overlay
  </Dialog>
</PortalContainerProvider>

Known caveats

  • Positioning: Base UI's own default positioner is position: absolute, which shares its portal container's containing block — inside a overflow: hidden shadow-root host, that clips the popup at the host's own edge. PopoverContent accounts for this: when portaled into a resolved portalContainer, it defaults positionMethod to 'fixed' instead, which escapes a plain overflow-clipping ancestor. 'fixed' still breaks if an ancestor has transform, contain, will-change, filter, or backdrop-filter set — those create a new containing block for fixed positioning too. Avoid these CSS properties on shadow-root ancestors. The other portaling components (Tooltip, Toast, DropdownMenu, InputSelect, Combobox, Dialog, Sheet) don't yet default positionMethod this way, so the example above — a Dialog inside a PortalContainerProvider — can still clip at the shadow host's edge.
  • z-index: Portaled content inside a shadow root participates in the shadow root's stacking context, not the document's. This is usually desirable (popups stay inside the MFE), but means a shadow-hosted popup cannot overlay content outside the shadow root.
  • Focus management: Base UI's focus trapping and restoration work across shadow boundaries in modern browsers (Chrome 90+, Firefox 96+, Safari 16.4+).

API reference

Prop

Type

Edit on GitHub

On this page