Modals, Overlays & Portals
Build overlay components that escape the layout using @portal
An overlay component lives a double life. The trigger — the button or badge the user clicks — belongs exactly where the user dropped the component, deep inside whatever grid, card, or slider they built. The panel it opens belongs somewhere else entirely: layered over the whole page, above everything. This guide walks through the complete anatomy of that split — an inline trigger, a panel teleported to bodyEnd with @portal, and an open/close signal that travels between them as a window-level event.
This guide dissects the Modal (com.realmacsoftware.modal) and Modal Close (com.realmacsoftware.modalClose) components from the open-source Core Pack. Open their source alongside this page.
Why Overlays Break Inside the Layout
Render a full-screen panel where the component sits in the document and three CSS mechanisms conspire against you:
Ancestor
overflow— any ancestor withoverflow: hiddenoroverflow: auto(sliders, cards, scroll areas) clips your panel to its own box.Ancestor
transform— a transformed ancestor (scroll animations, hover effects) becomes the containing block forposition: fixeddescendants, so your "full-screen" panel positions itself relative to a card instead of the viewport.Stacking contexts — an ancestor with its own
z-indexand stacking context caps your panel's stacking order. Noz-index: 9999on the panel can escape it; a sibling section later in the page can still paint on top.
You can't control any of this, because in Elements your component gets dropped into layouts you have never seen. The fix is not more CSS — it is moving the panel's markup out of the layout altogether. That is what @portal does: it transports a section of your template to a fixed page location. For overlays, the destination is bodyEnd — the end of the <body> element, outside every layout container, where fixed positioning and z-index behave predictably.
Anatomy of the Core Modal
The Modal's templates/index.html is short enough to read whole, and it splits cleanly into the two halves described above.
The Inline Trigger
The first half stays inline — it renders wherever the user drops the Modal:
<!-- templates/index.html -->
<span
@click="$dispatch('open-modal', { id: '{{id}}' })"
class="{{classes.trigger}}"
>
@dropzone("trigger", title: "Trigger")
</span>The trigger is a @dropzone, so the user decides what opens the modal — a Button, an Image, a line of text. Clicking anywhere in the span dispatches an open-modal event carrying this instance's id (more on that handshake below).
The hooks.js side wraps the whole component in an Alpine scope and wires up the optional auto-open behaviours:
Note that both the click handler and the root scope's openModal() do the same thing: dispatch open-modal with the instance id. Auto-open on page load, after a delay, or on exit intent all reuse the one function. (Helpers like classnames and getAlpineTransitionAttributesMobile are shared Core Pack utilities bundled into the compiled hooks.js at build time — hooks.source.js is the authored source.)
The Teleported Panel
The second half of the template never renders where the component sits. It is wrapped in @portal("bodyEnd"), so Elements transports it to the end of <body>:
Walking through it:
@portal("bodyEnd") … @endportal— the whole dialog lands at the end of<body>. There is deliberately noincludeOncehere: unlike a shared<script>portal, this block is per-instance markup, and every Modal on the page needs its own panel atbodyEnd. (The destination can be written bare or quoted —@portal(bodyEnd)and@portal("bodyEnd")are equivalent.)x-dialog— the headless dialog behaviour from the Core Pack's shared Alpine UI plugin (shared/assets/alpine-ui.js). It contributes focus trapping, Escape-to-close, and thex-dialog:overlay/x-dialog:panelroles on the inner elements.x-model="open"binds the plugin's open state to the localopenvariable, so writingopen = trueopens the dialog and the plugin closing it (Escape,$dialog.close()) writesfalseback.@open-modal.window="(e) => { open = e.detail.id === '{{id}}' }"— the receiving end of the trigger's dispatch. The.windowmodifier listens at the window level, which is what makes the whole scheme work (next section).The overlay and panel — a fixed, tinted overlay (
x-transition.opacityfades it) behind a centred panel. Clicking the panel wrapper closes the modal;@click.stopon the inner panel stops clicks on the content from bubbling up and closing it. Thecontentdropzone travels with the portal, so everything the user drops into the modal renders inside the teleported panel.stopAllMedia()— a real-world nicety: a$watchonopenpauses any<video>/<audio>and resets<iframe>embeds when the modal closes, so a playing YouTube embed doesn't keep blaring behind a closed modal.
The Id Handshake
Why does the event carry an id at all? Because the portal severs the DOM relationship between the two halves. Once the dialog is teleported to bodyEnd, it is no longer a descendant of the component's root element — so it cannot inherit the trigger's Alpine scope, and the trigger cannot reach it through $refs or shared x-data. A window-level event is the reliable channel between two elements that are no longer related in the DOM.
But window-level means every modal's listener hears every open-modal event. The handshake sorts it out:
The trigger dispatches
open-modalwith{ id: '{{id}}' }— the instance id fromrw.node.id, unique per Modal on the page.Every dialog's listener runs
open = e.detail.id === '{{id}}', comparing against its own instance id.
Only the matching dialog gets open = true. And note the elegant side effect of assigning the comparison result rather than guarding with it: every non-matching modal sets open = false, so opening one modal automatically closes any other — at most one modal is ever open, with no coordination code.
Edit Mode: Keeping the Panel on the Canvas
A modal that only exists after a click would be unusable in the editor — the user could never fill its content dropzone. The Modal handles edit mode with two small, deliberate moves.
First, the x-cloak that hides the dialog until Alpine boots is applied only outside edit mode:
In a published page, x-cloak prevents the dialog from flashing on screen before Alpine initialises and hides it. On the edit canvas, the attribute is omitted so the panel can render and be edited directly.
Second, hooks.js adjusts the dialog and overlay classes when the project is in edit mode:
modalShowInEdit is a Show switch in the Modal's properties.json. When it is off in edit mode, the dialog gets hidden — the canvas shows just the trigger, as the published page would. When it is on, the panel appears on the canvas for editing, and the full-screen overlay gets pointer-events-none so it never swallows clicks meant for the canvas underneath. This give-the-user-a-toggle pattern — and edit-mode canvas behaviour in general — is covered in depth in Edit Mode Experience.
Pairing Components: Modal Close
Opening is only half the contract. The Core Pack ships a companion component, Modal Close (com.realmacsoftware.modalClose), that the user drops inside the modal's content to close it from anywhere — a styled button, an X icon in the corner, a "No thanks" text link. It is tiny; this is essentially the whole component:
The entire component is a click surface around a dropzone. It works because of where it renders at runtime: dropped into the Modal's content dropzone, it travels with the portal into the teleported dialog, so in the final DOM it is a descendant of the x-dialog element. Alpine's $dialog magic (from the same shared plugin) resolves to the nearest enclosing dialog and closes it — no id handshake needed on the way out, because DOM ancestry is intact inside the panel.
This trigger/receiver pairing — one component dispatching or acting, a sibling component designed to live inside it — generalises well beyond modals. See Component Suites for the full pattern.
Variations on the Skeleton
The Modal's skeleton — inline trigger, @portal("bodyEnd") panel, window event with an id payload, @if(!edit)-gated x-cloak — is a template for any overlay. Two sketches show how little changes. Both are plain Alpine (no x-dialog plugin), so they handle their own Escape key and dismissal.
Off-Canvas Drawer
Same skeleton; only the positioning and transition classes change — the panel pins to the viewport edge and slides instead of fading:
Toast Notification
A toast drops the blocking overlay entirely and adds an auto-dismiss timer. The timer needs cleanup: clear any pending timeout before arming a new one, so rapid re-triggers extend the toast instead of letting a stale timer dismiss it early:
The duration comes from a number property, converted to milliseconds in the hook:
Note the toast guards with if (e.detail.id === '{{id}}') rather than assigning the comparison — unlike modals, several toasts may legitimately be visible at once, so a non-matching event should leave other toasts alone. For richer Alpine behaviour in overlays — shared factories, alpine:init registration, passing structured config — see Interactive Components with Alpine and JavaScript Templates.
Related Documentation
@portalDirective — destinations,includeOnce, and deduplication ids@dropzoneDirective — user-fillable regions like the trigger and contentInteractive Components with Alpine — Alpine patterns used throughout this guide
Edit Mode Experience — designing components that behave well on the canvas
JavaScript Templates — shipping scripts through portals
Last updated
Was this helpful?

