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”, somountclient-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.
| Target | Hook | Lives in | Use it when |
|---|---|---|---|
| In-shadow container | usePortalContainer() | div.hr-portal-container, a sibling of your react-root | The default. The dialog fits inside the widget’s box and nothing on the host page clips it. |
| Elevated singleton | useTopLevelPortal({cssString, enabled}) | Its own shadow root inside div.hr-dialog-portal-host at document.body | A 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-hostappended todocument.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 ownattachShadow({mode: "open"}); - inside that shadow root, a cloned copy of your stylesheet links, one
<style>fed fromcssString, anddiv.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:
- Scope tokens to
:host, or to a class you also put on the portal content.:hostresolves 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 yourDialogContentsubtree 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, inpackages/homerunner-embeddables/widgets/explorer/hooks/elevatedScope.ts) after shipping without it and painting light-mode popovers over dark widgets. - Sanitise before interpolating. Accent colours, font family and
customCssare admin-supplied strings going intodangerouslySetInnerHTML, 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 foruseTopLevelPortalonly when the host page clips or under-stacks the dialog. - Never let a portal target be
undefinedon purpose — that is the unstyleddocument.bodyfallback, not a feature. - Build one token string; render it with
<Stylesheet>and pass it ascssString, scoped to:hostor to a layout-free class you also put on the dialog’s root element. - Keep
mount’sshadowDOMin step with the manifest’s: mountingshadowDOM: falseunder 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
- Styling and theming — dual-emit dark selectors,
<Stylesheet>, and why layout CSS is document-level. - Runtime, mount and the DOM contract — the shadow
lifecycle, the DOM attribute contract, the
shadowDOMdisagreement matrix. - SDK reference — every
/runtimeexport, author-facing versus platform-internal. - How a widget renders — where the DSD template comes from.