Portal
<Portal> relocates its children to another DOM node — by default document.body, or a selector / element you pass as to. Use it when an ancestor's overflow, z-index, or transform would clip or bury UI that should float above the page: toasts, tooltips, menus, and custom overlays.
import { Portal } from "@opentf/afw"; {open && ( <Portal to="body"> <div class="toast">Saved!</div> </Portal> )}
On connect the runtime moves the portal's light-DOM children to the target; on unmount it removes them so custom-element disconnect and effect cleanups run. No manual DOM bookkeeping.
The box is overflow: hidden. An inline overlay would be clipped —<Portal to="body"> relocates the modal to <body>.
Ancestor clips anything that stays inside.
For modals, <dialog> uses the browser top layer — no portal needed. Backdrop and Esc-to-close are built in.
Open the portaled modal inside the clipped box — it renders on document.body, not inside the clip region. A custom overlay has to supply what <dialog> gives for free, and the demo does: role="dialog" with aria-modal and a labelled title, focus moved inside on open and kept there with Tab, Escape and backdrop clicks to close, the page behind made inert, and focus returned to the opening button. Compare with the native <dialog> below the demo.
The to target
to value | Resolves to |
|---|---|
| omitted | document.body |
"body" / "#root" / any selector | document.querySelector(…), falling back to body |
a DOM Element | that element directly |
<Portal to="#toast-root"> <p>Rendered into the element with id="toast-root".</p> </Portal>
Portals vs native <dialog>
For modals and dialogs, the browser top layer is often simpler — no relocation, free backdrop, Esc-to-close:
const dialogRef = $ref(); <button onclick={() => dialogRef.showModal()}>Open</button> <dialog ref={dialogRef}> <p>Native top layer</p> <button onclick={() => dialogRef.close()}>Close</button> </dialog>
Reach for <Portal> when you need a general escape hatch — stacked toasts, custom overlays, tooltips anchored to arbitrary targets, or markup that does not map cleanly to <dialog>.
On first paint the portal defers relocation until hydration finishes so slotted content can adopt server-rendered DOM in place. On a client-only build the move runs immediately. See Hydration.
Runtime API
| Export | Description |
|---|---|
Portal | Built-in custom element — use as <Portal to={…}>…</Portal> in JSX. |
Also listed on the Runtime API reference.
Related
Context — portaled subtrees still resolve
$contextfrom the logical tree.Error boundaries — throws inside portaled content route to the nearest boundary.
Components — where portals fit in the tree.
Imperative API —
$reffor native<dialog>.