A portal webcomponent
When building a tooltip using web components, 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 webcomponents? 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>
By default, it makes sense to portal it to <body>
but it can also be useful to accept a selector to customize the portal destination, so let’s add a to
attribute.
<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 web 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 use skip links, or by manually 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 have 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.