A portal renders an element outside its parent DOM hierarchy. This is useful for overlays, modals, and tooltips that need to escape overflow: hidden, z-index stacking contexts, or other CSS containment.
import { createPortal } from '@barefootjs/dom'#`createPortal`
Moves an element to a different container in the DOM.
createPortal(children, container?, options?)Type:
type Portal = {
element: HTMLElement
unmount: () => void
}
interface PortalOptions {
ownerScope?: Element // Component scope for scoped queries
}
function createPortal(
children: HTMLElement | string,
container?: HTMLElement, // Default: document.body
options?: PortalOptions
): PortalReturns a Portal object with:
element— the mounted DOM elementunmount()— removes the element from the container
#Basic Usage
Portals are typically created inside a ref callback. The element is moved to document.body (or another container) after it is mounted:
"use client"
import { createSignal, createEffect, createPortal, isSSRPortal } from '@barefootjs/dom'
export function Tooltip(props: { text: string; children?: Child }) {
const [visible, setVisible] = createSignal(false)
const handleMount = (el: HTMLElement) => {
// Move to document.body to avoid overflow/z-index issues
if (el.parentNode !== document.body && !isSSRPortal(el)) {
const ownerScope = el.closest('[bf-s]') ?? undefined
createPortal(el, document.body, { ownerScope })
}
createEffect(() => {
el.hidden = !visible()
})
}
return (
<div>
<span
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
{props.children}
</span>
<div className="tooltip" ref={handleMount}>
{props.text}
</div>
</div>
)
}#SSR Portal Detection
When a portal is server-rendered, it is already in the correct position in the DOM. isSSRPortal checks whether an element was already portaled during SSR to prevent double-portaling:
import { isSSRPortal } from '@barefootjs/dom'
const handleMount = (el: HTMLElement) => {
// Skip if already portaled during SSR
if (el.parentNode !== document.body && !isSSRPortal(el)) {
createPortal(el, document.body)
}
}SSR portals are marked with bf-pi attributes. After hydration, call cleanupPortalPlaceholder to remove the SSR placeholder:
import { cleanupPortalPlaceholder } from '@barefootjs/dom'
cleanupPortalPlaceholder(portalId)#Owner Scope
By default, an element moved to document.body via a portal is outside its original component's scope. This means the runtime's find() function cannot locate it when searching within the component boundary.
The ownerScope option solves this by linking the portaled element back to its parent component:
const handleMount = (el: HTMLElement) => {
const ownerScope = el.closest('[bf-s]') ?? undefined
createPortal(el, document.body, { ownerScope })
}The portal sets bf-po on the moved element, so scoped queries from the owner component still find it.
#Dialog Example
A common use of portals is moving dialog overlays and content to document.body:
"use client"
import { createPortal, isSSRPortal, useContext, createEffect } from '@barefootjs/dom'
function DialogOverlay() {
const handleMount = (el: HTMLElement) => {
// Portal to body
if (el.parentNode !== document.body && !isSSRPortal(el)) {
const ownerScope = el.closest('[bf-s]') ?? undefined
createPortal(el, document.body, { ownerScope })
}
const ctx = useContext(DialogContext)
// Reactive visibility
createEffect(() => {
const isOpen = ctx.open()
el.dataset.state = isOpen ? 'open' : 'closed'
el.className = isOpen ? 'overlay overlay-visible' : 'overlay overlay-hidden'
})
// Click overlay to close
el.addEventListener('click', () => {
ctx.onOpenChange(false)
})
}
return <div data-slot="dialog-overlay" ref={handleMount} />
}The overlay is rendered inside the <Dialog> component tree (so it can access DialogContext), but is moved to document.body by the portal (so it escapes any CSS containment).
#Cleanup
Use portal.unmount() to remove a portaled element. Combine with onCleanup to clean up when a component is destroyed:
import { createPortal, onCleanup } from '@barefootjs/dom'
const handleMount = (el: HTMLElement) => {
const portal = createPortal(el, document.body)
onCleanup(() => {
portal.unmount()
})
}#Custom Container
By default, createPortal appends to document.body. You can specify a different container:
const container = document.getElementById('modal-root')!
createPortal(el, container)This is useful when you have a dedicated mount point in your HTML layout for modals or notifications.