Portal renders its content somewhere else in the DOM, and PortalTarget marks the places it can render to. Reach for them when content has to escape a parent's overflow: hidden or stacking context — modals, drawers, popovers, tooltips, toasts — or when it belongs in a specific region of the page for reading order or z-index reasons.
import { Portal, PortalTarget } from 'frontile';
With no arguments, Portal renders into the closest destination it can find, falling back to document.body.
import { Portal } from 'frontile';
<template>
<Portal>
<div class='p-4 border border-neutral-soft rounded shadow-md'>
This content is rendered in a portal.
</div>
</Portal>
</template>
PortalTarget renders a div marked with data-portal-target="true" and, when @for is
given, data-portal-for="<name>". It takes attributes and a block, so it can be positioned
and styled like any other element and can hold content of its own.
A target with no @for is unnamed: it is the default destination for any Portal
rendered below it that has no @target of its own. A target with @for is only used by
portals that ask for it by name, so it never captures unrelated content.
import { Portal, PortalTarget } from 'frontile';
<template>
<div class='flex flex-col space-y-4'>
<PortalTarget />
<PortalTarget @for='target-1' />
<Portal @target='target-1'>
<div class='p-4 border border-neutral-soft rounded shadow-md'>
Rendered to target-1
</div>
</Portal>
</div>
</template>
Because named targets are opt-in, several can coexist and each Portal picks one by name.
The target's own block content stays put; portal content is appended after it.
import { Button, Portal, PortalTarget } from 'frontile';
<template>
<div class='flex gap-4'>
<PortalTarget
@for='toolbar'
class='flex-1 flex items-center gap-2 p-3 border border-neutral-soft rounded'
>
<span class='text-neutral-strong'>Toolbar</span>
</PortalTarget>
<PortalTarget
@for='footer'
class='flex-1 flex items-center gap-2 p-3 border border-neutral-soft rounded'
>
<span class='text-neutral-strong'>Footer</span>
</PortalTarget>
<Portal @target='toolbar'>
<Button @size='sm'>Save changes</Button>
</Portal>
<Portal @target='footer'>
<Button @size='sm' @appearance='outlined'>Cancel</Button>
</Portal>
</div>
</template>
Without a target, overlays land in document.body, outside the element your app styles and
outside its stacking context. Rendering one unnamed PortalTarget near the end of the
application template gives every overlay a predictable home you control — this documentation
site does exactly that:
import { PortalTarget } from 'frontile';
<template>
{{outlet}}
<PortalTarget class='relative z-20' />
</template>
Portal resolves its destination in this order:
@renderInPlace={{true}} — no portal at all, the content stays where it is written.@target as an Element — that element.@target as a string beginning with # — the element with that id.@target as any other string — the nearest PortalTarget with a matching @for.@appendToParentPortal={{false}}.PortalTarget.document.body.When the destination ends up being plain document.body, Portal wraps its content in a
PortalTarget for you, so portals nested inside it still have somewhere to go.
Portals nest: an inner Portal renders into the destination of the outer one.
import { Portal, PortalTarget } from 'frontile';
<template>
<div class='h-40 relative w-32'>
<PortalTarget />
</div>
<Portal
class='absolute w-content bg-neutral/50 left-0 p-4 border border-neutral-soft rounded'
>
First portal (Outer)
<Portal
class='absolute w-content bg-primary/50 border border-primary left-12 rounded'
>
Second portal (Inner)
<Portal
class='absolute w-content bg-danger/50 border border-danger left-16 rounded'
>
Last portal (Inner 2)
</Portal>
</Portal>
</Portal>
</template>
@renderInPlace={{true}} skips the portal entirely and renders the content where it is
written — useful for turning portalling off conditionally.
import { Portal } from 'frontile';
<template>
<div class='p-4 border border-neutral-soft rounded'>
<Portal @renderInPlace={{true}}>
This content is rendered inline instead of a portal.
</Portal>
</div>
</template>
@target also accepts an element id prefixed with #, or an Element reference, for
destinations that are not PortalTargets — a third-party container, for instance.
import { Portal } from 'frontile';
<template>
<div id='target' class='border border-neutral-soft p-4'>
Target Element
</div>
<Portal @target='#target'>
<div class='p-4 border border-neutral-soft rounded shadow-md'>
Rendered inside target element.
</div>
</Portal>
</template>
By default a Portal appends to the parent portal when there is one. Set
@appendToParentPortal={{false}} to make it resolve a destination on its own instead, which
sends it to the nearest unnamed PortalTarget.
import { Portal, PortalTarget } from 'frontile';
<template>
<PortalTarget />
<Portal>
Outer portal content
<Portal @appendToParentPortal={{false}}>
This content is rendered separately, not appended to the parent portal.
</Portal>
</Portal>
</template>
Both components work under FastBoot. Portal resolves its destination from the owner's
document rather than the global document, so portalled content is present in the
server-rendered HTML.
Neither component adds a role or any ARIA attribute — PortalTarget is a plain div, and
that is deliberate: the semantics belong to whatever you render inside it. What they do
change is DOM position, and that has consequences:
PortalTarget after the main content so overlay content comes last.Portal does not move, trap, or restore focus.
For dialogs use Modal, Drawer, or Overlay, which handle focus, focus trapping, and
the Escape key on top of Portal.aria-controls,
aria-describedby, or aria-labelledby, since proximity in the DOM no longer implies it.Portal at a time.These notes come from reading portal.gts and portal-target.gts and the integration tests
in test-app/tests/integration/components/overlays/; the tests cover destination resolution,
not assistive-technology behavior.
Element: <span class="hljs-title class_">HTMLElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
for
|
string
|
- |
Name of this target, matched against a When omitted, the target is unnamed: any |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: <span class="hljs-title class_">HTMLDivElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
appendToParentPortal
|
boolean
|
true
|
|
renderInPlace
|
boolean
|
false
|
Whether to render in place or in the specified/default destination |
target
|
enum
|
- |
The target where to render the portal.
There are 3 options: 1) For element id, string must be prefixed with |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |