Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Portals and dialogs

Your widget renders inside a shadow root. A dialog rendered in place inherits that root’s styles — and also its clipping, its overflow, and every stacking context between the host element and the viewport. React portals move the dialog elsewhere in the DOM. This page is about where, because the wrong target silently drops every stylesheet you rely on. Everything here ships in @homerunner-next/widget-core/runtime at the 0.10.0 publish floor, so no recipe below needs a newer SDK — see Install and versions.

The shadow structure you portal into

Two code paths build the same shape, and both name their parts with class names, not data attributes. mount adopts a server-emitted root by scanning for hr-react-root; if that class is missing, adoption fails and it rebuilds from scratch.

<!-- packages/homerunner-renderer/lib/render-utils.tsx:745-747 — server-emitted DSD -->
<div data-hr-widget="plugin:acme-weather:forecast" id="{widgetId}:{feedId}" data-hr-ssr-id="…">
  <template shadowrootmode="open">
    <link rel="stylesheet" href="…">       <!-- your CSS, plus any declared fonts -->
    <div class="hr-react-root">…your server markup…</div>
    <div class="hr-portal-container"></div>
  </template>
</div>
// packages/homerunner-widget-core/src/runtime/shadow-dom.ts:85-120 — client-built
[data-hr-widget="plugin:acme-weather:forecast"]      ← shadow host
  └─ #shadow-root (open)
       ├─ <link rel="stylesheet">                    ← one per resolved CSS URL
       ├─ <div class="hr-react-root">                ← React renders/hydrates here
       └─ <div class="hr-portal-container">          ← in-shadow portal target

setupShadowDOM(hostEl, cssUrls) returns {shadowRoot, shadowHost, reactRoot, portalContainer, cssReady, cssPending}. cssPending is the field most often missed: it says whether the react-root must stay hidden until cssReady settles. It is true whenever the <link>s were only just connected (a fresh client build, or the DSD ponyfill attaching a root around an inert template) and false for native Declarative Shadow DOM, which the browser already styled at parse. cssReady resolves when every sheet applies, on error, or after a hard timeout — see Limits and error index.

adoptDeclarativeShadowRoot and waitForShadowLinks are internal and not exported; setupShadowDOM, migrateSSRContent, getCSSUrlsFromElement and isShadowDOMSupported are. The adopt-versus-migrate order, the visibility gate and the shadowDOM disagreement matrix are in Runtime, mount and the DOM contract.

Not supported yet. A widget whose entire server output is a hoistable tag — a lone <style>, <link>, <script>, <meta>, <title> or <base> — reads as “no server markup”, so mount client-renders instead of hydrating. If your component’s top level is a bare <Stylesheet>, wrap it in a real element.

Two portal targets, and one failure that looks like a third

Both shapes. There are exactly two targets the platform gives you.

TargetHookLives inUse it when
In-shadow containerusePortalContainer()div.hr-portal-container, a sibling of your react-rootThe default. The dialog fits inside the widget’s box and nothing on the host page clips it.
Elevated singletonuseTopLevelPortal({cssString, enabled})Its own shadow root inside div.hr-dialog-portal-host at document.bodyA sticky sidebar, an overflow: hidden navbar, a fixed banner or a small card would clip or under-stack the dialog.

There is no third target. If no provider is in scope, usePortalContainer() returns undefined and Radix falls back to the document body:

// @radix-ui/react-portal 1.1.x, dist/index.mjs — the fallback that bites
const container = containerProp || (mounted && globalThis?.document?.body);

When your widget lives in a shadow root, document.body is outside it, so none of your stylesheets reach the dialog: it renders as unstyled browser-default HTML over the customer’s page. Treat that as a bug, never a design. Two ways to land there: portalling from a component that is not a descendant of mount’s providers, or calling useTopLevelPortal on a widget that has no shadow host — the hook needs one to clone stylesheets from and returns undefined without it, falling through to the same fallback. (With shadowDOM: false the fallback is harmless, because the whole widget is already in the light DOM.)

The providers come from WidgetHydrationTree on the hydrate path and from mount directly on the CSR path. On the server both are empty — the renderer passes no elements — and Radix’s Portal renders null until it has mounted, so dialog content is always client-only and has no server markup to mismatch against.

The default: usePortalContainer()

The scaffold’s DialogContent already does this, so most widgets never call the hook directly:

// packages/create-hr-plugin/template/src/components/ui/dialog.tsx:112-117
const portalContainer = usePortalContainer();
const elevatedPortal = useTopLevelPortal({ cssString, enabled: !!elevateDom });
const effectiveContainer = elevateDom ? elevatedPortal : portalContainer;

return <DialogPortal container={effectiveContainer}>…</DialogPortal>;

Note DialogPortal. It is a separate named export from the same module, because Dialog is a re-export of Radix’s Root and has no .Portal property — <Dialog.Portal> is a TypeError, not an API. The vendored Tooltip wires usePortalContainer() the same way.

Because this container sits inside your own shadow root, everything already works: your stylesheet, your CSS variables, your :host([data-hr-theme="dark"]) rules. Nothing to pass.

Escaping the widget: useTopLevelPortal

useTopLevelPortal({cssString, enabled}) builds, on first use (packages/homerunner-widget-core/src/runtime/elevated-portal.tsx:87-136):

  • a refcounted singleton div.hr-dialog-portal-host appended to document.body — fixed, 0 × 0, overflow: visible, z-index: 99999. It is created on the first mount and removed when the last elevated portal unmounts;
  • inside it, a per-dialog wrapper <div> with its own attachShadow({mode: "open"});
  • inside that shadow root, a cloned copy of your stylesheet links, one <style> fed from cssString, and div.hr-dialog-portal-root — the element the hook returns.

enabled: false skips all of it and returns undefined, which is how an elevateDom-style switch falls back to the in-shadow container. The two-field option type is normative in SDK reference.

The token trap

This is the failure everyone hits once. Read the two lines that cause it:

// packages/homerunner-widget-core/src/runtime/elevated-portal.tsx:105-119
const stopThemeMirror = mirrorHostTheme(shadowHost, wrapper);          // theme IS mirrored
const links = originalShadowRoot.querySelectorAll('link[rel="stylesheet"]');
links.forEach((link) => shadow.appendChild(link.cloneNode(true)));     // ONLY <link> is cloned
const styleEl = document.createElement("style");                       // cssString goes here

Only link[rel="stylesheet"] elements are cloned. Your compiled stylesheet — Tailwind utilities, your component classes — comes across intact. Everything you rendered as an inline <style> does not: accent colours, font.size, font.family and the customer’s customCss are all computed from options at render time and injected with <Stylesheet>, which is a <style> tag inside the react-root. In the elevated portal those declarations are simply absent, so every var(--your-token, <light fallback>) resolves to its fallback — a dialog that looks almost right in light mode and paints a white panel over a dark widget.

data-hr-theme is the exception. mirrorHostTheme copies it onto the portal wrapper and keeps it live with a MutationObserver, so an OS dark-mode flip while a dialog is open re-themes the dialog too, and :host([data-hr-theme="dark"]) rules inside the cloned sheets do match. The parse-time data-hr-scheme="auto" arm is not mirrored and need not be: the runtime replaces it with a concrete data-hr-theme the moment JS takes over, long before a dialog can open.

The fix: emit your tokens once, feed them to both

// derived from packages/homerunner-embeddables/widgets/split-cost/widget.tsx:191-226
const tokenCss = useMemo(
  () => `
    :host { font-size: ${cfg.font.size}px; }
    .acme-scope { --acme-accent: ${cfg.lightModeColors.accent}; }
    :where(:host([data-hr-theme="dark"])) .acme-scope {
      --acme-accent: ${cfg.darkModeColors.accent};
    }
    ${cfg.customCss ?? ""}
  `,
  [cfg.font.size, cfg.lightModeColors.accent, cfg.darkModeColors.accent, cfg.customCss],
);

return (
  <div className="acme-scope" style={resolveWidgetSpacingStyle(cfg)}>
    <Stylesheet css={tokenCss} />
    <Dialog>
      <DialogTrigger>Open</DialogTrigger>
      {/* the scope class must be on the portalled content too — see rule 1 below */}
      <DialogContent className="acme-scope" elevateDom cssString={tokenCss}>…</DialogContent>
    </Dialog>
  </div>
);

Two rules make the same string work in both shadow roots:

  1. Scope tokens to :host, or to a class you also put on the portal content. :host resolves to your widget host in one root and to the portal wrapper in the other, so it works in both. A class selector only matches what is actually inside the portal — and Radix renders only your DialogContent subtree there, not your outer wrapper. If your variables hang off a container class, add a layout-free scope class to the dialog’s own root element and include it in the token selector list. The platform’s explorer added exactly that (ELEVATED_TOKEN_SCOPE, in packages/homerunner-embeddables/widgets/explorer/hooks/elevatedScope.ts) after shipping without it and painting light-mode popovers over dark widgets.
  2. Sanitise before interpolating. Accent colours, font family and customCss are admin-supplied strings going into dangerouslySetInnerHTML, twice over.

cssString syncs in place through its own effect, so changing a token neither tears down the portal nor closes an open dialog.

elevateDom and cssString are props of DialogContent (and of the scaffold’s ResponsiveDialogContent, which forwards both), never of Dialog. The dual-emit dark selectors and the <Stylesheet> API belong to Styling and theming.

Radix’s accessibility warnings cannot see into a shadow root

Both portal targets put your DialogTitle inside a shadow tree. Radix checks for it with document.getElementById(titleId), which only searches the document’s own node tree, so the check fails and logs, on every open, from an otherwise correct dialog:

`DialogContent` requires a `DialogTitle` for the component to be accessible for screen reader users.
Warning: Missing `Description` or `aria-describedby={undefined}` for {DialogContent}.

Both are false positives — the aria-labelledby wiring inside the portal is correct. The platform’s own dialog fixes them; the scaffold’s vendored copy does not, so add this to your DialogContent if the noise matters:

// packages/homerunner-embeddables/components/ui/dialog.tsx:144-182 — the two fixes
<DialogPrimitive.Content aria-describedby={undefined} …>   {/* opts out of the description check */}
…
{/* sibling of DialogPortal, not a child, so Radix's Portal does not swallow it */}
{elevateDom && mounted && createPortal(
  <DialogPrimitive.Title style={srOnlyStyle} aria-hidden>{title ?? ""}</DialogPrimitive.Title>,
  document.body,
)}

Gate the mirror behind a post-mount useState/useEffect flag, as shown. typeof document differs between the server render and the hydration pass, and a structural difference there makes React discard your whole SSR tree.

useShadowHost()

Returns the element the shadow root is attached to, or null when shadow DOM is off. Three real uses: dispatching events the host page can hear (only the host is visible from outside the boundary); measuring, since getBoundingClientRect() on the host gives the widget’s box on the customer’s page and a shadow root has no box of its own; and reaching the light DOM for a third-party SDK that mounts by id into document and cannot see inside a shadow tree.

What it is not for: reading data-hr-options or data-hr-css off the host. Server-rendered pages moved props, options and CSS into a page-level registry keyed by data-hr-ssr-id; those attributes survive only on hand-written CSR embeds and old cached pages. The attributes actually present on a host today are listed in Runtime, mount and the DOM contract.

Events across the shadow boundary

Outbound — a custom event must be marked composed to cross an open shadow root, and dispatched on the host so the host page’s listeners see it:

// packages/homerunner-embeddables/widgets/related-properties/impressions.ts:55
hostEl?.dispatchEvent(
  new CustomEvent("acme:opened", { detail, bubbles: true, composed: true }),
);

Inbound — a document-level listener sees event.target retargeted to your shadow host, so root.contains(e.target) reads every click inside the widget as outside. Outside-close handlers must use composedPath():

// packages/homerunner-embeddables/widgets/explorer/components/filters/pro/ProWhereField.tsx:37-40
const isEventInsideRoot = (root: Node, e: Event): boolean =>
  typeof e.composedPath === "function"
    ? e.composedPath().includes(root)
    : root.contains(e.target as Node);

One consequence worth planning for: an elevated portal is a different shadow tree hanging off document.body, so a containment check against your own root correctly reports clicks inside an elevated dialog as outside your widget. Check containment against the portal target too, or let Radix’s own dismissable-layer handling own the close.

Layout widgets have no portal container on a server-rendered page

Suite, category: "layout" only. On a server-rendered customer page the layout shell is emitted as static light DOM — no <template shadowrootmode>, no shadow root — and its client mount is a deliberate no-op. Inside a layout shell on that path usePortalContainer() returns undefined, useShadowHost() returns null, and nothing ever hydrates: a dialog there can never open. Put interactive UI in a content widget bound into a slot — each slot child is its own island with its own shadow root and portal container. The same layout embedded as a CSR snippet does get a shadow root and full providers through LayoutCsrRenderer, which is why the asymmetry is easy to miss locally. Full contract: Layout widgets.

Checklist

  • Default to usePortalContainer(); reach for useTopLevelPortal only when the host page clips or under-stacks the dialog.
  • Never let a portal target be undefined on purpose — that is the unstyled document.body fallback, not a feature.
  • Build one token string; render it with <Stylesheet> and pass it as cssString, scoped to :host or to a layout-free class you also put on the dialog’s root element.
  • Keep mount’s shadowDOM in step with the manifest’s: mounting shadowDOM: false under a manifest that allows shadow DOM leaves no shadow host to clone from, so every elevated portal degrades to the body fallback on top of the stranding described in the mount contract.
  • Verify in dark mode, from a widget inside a sticky container, on a real page — not only in the dev sandbox.

See also