A portal custom element
When building a tooltip, I decided to append the tooltip content to the end of <body> to avoid any stacking context issues. This concept is known as portaling (see React’s createPortal and Vue’s Teleport APIs), and it is generally quite useful for any kind of floating elements (tooltips, dropdown menus, dialogs).
Can we port (ha!) the same idea to vanilla web component APIs? Let’s try.
Desired API
The API is actually straightforward. Our custom element can wrap the thing to be portaled.
<por-tal>
<div>portal me, I beg you</div>
</por-tal>Let’s add a to attribute that accepts a CSS selector that matches the portal destination. We can use <body> as a fallback, but know that it can sometimes cause performance issues.
<por-tal to=".somewhere-in-brooklyn">
<div>portal me, I beg you</div>
</por-tal>Implementation
We don’t want our portaled content to show up in the wrong place, so the very first thing we will do is add display: none. This can go in a CSS file or inline style.
por-tal {
display: none;
}It might feel weird at first, but it’s somewhat consistent with framework behavior — portals do not get rendered on the server in both React and Vue.
Now we enhance!
customElements.define(
"por-tal",
class extends HTMLElement {
// …
}
);Our custom element might have multiple children, and we want to portal all of them. We can store multiple nodes inside a fragment. And we’ll use replaceChildren to avoid having to loop through the individual child nodes.
connectedCallback() {
const content = document.createDocumentFragment();
content.replaceChildren(...this.childNodes);
}Now all we need to do is calculate the portal destination and append our content to it.
connectedCallback() {
// …
const destination = this.getAttribute('to')
? document.querySelector(this.getAttribute('to'))
: document.body;
destination.appendChild(content);
}Believe it or not, we’re done!
Try it yourself in this playground.
Self-destruct?
This is a run-once component, meaning it does not react to changes to children or attributes. You could pull something using mutation observers and observed attributes. Personally, I like the idea of the portal closing permanently. It just feels more dramatic this way.
connectedCallback() {
// …
this.remove();
}It probably wouldn’t be a good idea to use this inside a framework. The framework component might notice its children are gone and freak out like an overattached parent. 🫥
Accessibility considerations
It is important to note that this technique is a massive hack, and special care must be taken to not break sequential navigation for keyboard users and screen reader users. If the portal contains complex information or interactive elements, then it’s generally a good idea to provide a way to move back and forth between the “trigger” and the portaled content. This could be done by using aria-owns, or by providing skip links, or by programmatically moving focus to the portal content when it’s shown and returning focus to the original element when the portal content is hidden or when tabbing out of it.
The future: built-in popovers
Browsers are currently working on a popover attribute which will make portals mostly obsolete. A popover element always displays in the top-layer, thus avoiding any stacking context woes. It’s also much better for accessibility, since it doesn’t break sequential navigation (you get to explicitly control source order and focus order).
The popover API is easily feature detectable, so the portal technique can be conditionally deployed only in browsers where popover is not supported.