Portal
stimeo--portal
Moves an element elsewhere in the DOM (e.g. under body) on connect and cleans up on disconnect.
The stimeo--portal controller moves an element to another place in the DOM — the shared substrate for overlays that must escape an ancestor's overflow: hidden, transform, or stacking context. On connect it moves the content target (or this.element when there is none) into the first element matching to (default body), appended or prepended per position, leaving a comment placeholder to mark the original spot. On disconnect it returns the node there when restore is set, otherwise removes it, so no orphan is left behind (Turbo navigation included). The moved node carries data-portaled, and mount / unmount events fire with the destination. Behavior only — no positioning (pair with stimeo-ui/positioning) and no focus trapping (pair with a Focus Scope / the overlay). For Turbo prefer the content-target form so the controller stays on the in-place source and its disconnect reliably fires; the move is idempotent and survives the connect/disconnect churn some runtimes emit when an observed element is relocated.
The card is teleported to <body> on connect, so it escapes this clipped box and floats at the viewport corner.
Use case: lift tooltips, popovers, or modals out of an ancestor's overflow: hidden, z-index, or transform so they are not clipped or mis-stacked.
<%# Portal demo: the card is a content target, so on connect the controller teleports it
out of the dashed (overflow:hidden) source box and into <body>, where demo.css floats
it at the viewport corner — visibly escaping the clipping ancestor. demo.js reflects
the mount destination into the status line. On disconnect (e.g. Turbo navigation) the
card is restored, leaving no orphan. The library only moves the node and sets
data-portaled; demo.css owns the look. %>
<div class="portal-demo">
<div
class="portal-demo__source"
data-controller="stimeo--portal"
data-stimeo--portal-to-value="body">
<p class="portal-demo__hint"><%= t("components.portal.demo.hint") %></p>
<div class="portal-demo__card" data-stimeo--portal-target="content">
<%= t("components.portal.demo.card") %>
</div>
</div>
<p
class="portal-demo__status"
role="status"
data-portal-status
data-mounted-label="<%= t("components.portal.demo.mounted") %>"></p>
<p class="portal-demo__note"><%= t("components.portal.demo.note") %></p>
</div>
/*
* Presentation-only styles for the portal demo. The library moves the card to <body>
* and sets data-portaled; this CSS clips the source box (to show the card escaping it)
* and floats the teleported card at the viewport corner.
*/
.portal-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.portal-demo__source {
padding: 1rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
.portal-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
/* Teleported to <body>: pinned to the viewport corner, out of the clipped box. */
.portal-demo__card {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 50;
max-width: 16rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: var(--color-text);
color: var(--surface-page);
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.25);
}
.portal-demo__status {
margin: 0;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.portal-demo__note {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
// Portal demo (consumer-side JS).
//
// The controller teleports the card to <body> on connect; this JS only reflects where it
// landed into a status line. It both listens for the mount event and, in case connect
// already fired before this module ran, reports the current state once on load.
document.querySelectorAll(".portal-demo").forEach((root) => {
const status = root.querySelector("[data-portal-status]");
const source = root.querySelector('[data-controller="stimeo--portal"]');
if (!status || !source) return;
const label = status.dataset.mountedLabel ?? "Mounted into";
const report = (target) => {
status.textContent = `${label} <${(target?.tagName ?? "body").toLowerCase()}>`;
};
source.addEventListener("stimeo--portal:mount", (event) => report(event.detail.target));
// If the content has already left the source, the mount event fired before this ran.
if (!source.querySelector('[data-stimeo--portal-target="content"]')) {
report(document.body);
}
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--portal"
Targets
| Name | Description | Attribute |
|---|---|---|
content
|
Optional node to move; defaults to the controller element when absent. | data-stimeo--portal-target="content" |
Values
| Name | Description | Attribute |
|---|---|---|
to
|
CSS selector for the destination (default body). |
data-stimeo--portal-to-value |
position
|
append (end) or prepend (start) within the destination (default append). |
data-stimeo--portal-position-value |
restore
|
Return the node to its original spot on disconnect, else remove it (default true). |
data-stimeo--portal-restore-value |
Events
| Name | Description | Event |
|---|---|---|
mount
|
Fires after the node is inserted into the destination, with detail.target. |
stimeo--portal:mount |
unmount
|
Fires when the node is restored / removed. | stimeo--portal:unmount |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-portaled |
The moved node | Present (true) while the node is teleported. |