When last we met we were sending content throughportals as if we wereJack O'Neill sending soldiers to other galaxies. Not only that, we were doing it with vanilla javascript, thanks to the help ofShadow DOM andCustom Events, rather than with a framework as had some of the solid examples ofprior art that we checked out. If you haven't had the pleasure, or have forgotten much of what you read (join the club), don't worry, the rest of us will wait here for you...
...Your Content in Shadow DOM Portals...
...ok, now that we're all ready, there's no reason to bury the lede, today we're gonna talk about what those same techniques look like when taking advantage of the support of a simple base class for creating fast, lightweight web components;LitElement. And so, without further ado, here's what that looks like it all of its glory:
Well, maybe notall of its glory, more like in its one-to-one porting of the realities discussed and delivered with fully vanilla JS in the previous article. We've seen some of it before in theDeclarative API section of the previous article, but it's important to revisit it now as it will form the basis for extending the elements to support the ideas discussed across the wholeBut, now what? section therein. So, let's work up to full glory together!
Cross Browser Support
This was a large piece of any production possible code that I chose to leave out of our previous conversation for proof of concept's sake.We discussed some of the possibilities but didn't get into them, until now. The first place we'll run into an issue is with the use ofslot.assignedNodes()
. You may remember we had previously been usingslot.assignedElements()
, however, we want to be able to get loose text nodes as well as elements, soassignedNodes
is the way to go. Let's take a look at what the code relying on this looks like now:
projectSlot(e){if(!e.target.assignedNodes().length)return;this.dispatchEvent(createEvent('portal-open',{destination:this.destination,content:e.target.assignedNodes(),}));}
You may also remember that when relying on ShadyDOM in a polyfilled setting there is no support forassignedNodes
, so we'll need to do some extra work in order to enable the same functionality cross-browser. How sad that literally two lines of code charge such a tax on our goals here, but not to worry we can access similar results in this context with via[...el.childNodes]
. While inmost cases this would do this trick, because of the use of a<slot />
tag with noname
attribute we need to filter out a few possibly false positives before passing content on to our<portal-destination />
.
getportalContent(){constslot=this.shadowRoot.querySelector('slot');returnslot&&slot.assignedNodes?slot.assignedNodes():this.childrenWithoutSlots;}getchildrenWithoutSlots(){letnodes=[...(this.childNodes.length?this.childNodes:[])];nodes=nodes.filter(node=>node.slot===''||node.slot===null);returnnodes;}projectSlot(){letcontent=this.portalContent;if(!content.length)return;this.dispatchEvent(createEvent('portal-open',{destination:this.destination,content:content,}));}
If you're interested in following along with the above code in real life, there are several ways you can access older browsers. The nuclear option is working with tools likeBrowserStack, or you could rely on one of the Virtual Machines that Microsoft offers forvarious versions of Internet Explorer and Edge, but my current go-to isFirefox: Extended Support Release. Firefox ESR is an enterprise-targeted release of the Firefox that is currently shipping version 60 which initially released before the v1 web components specification was supported by Firefox. It doesn't make debugging very fun, as I've not figured out how to open the dev tools, howeveralert()
works just fine and I've leveraged it more than I'd like to admit...
In the realm of cross-browser support, the remaining context for us to cover is applying styles to the content when it reaches the destination end of the portal. This is really where things get tricky and force us to weigh the pros and cons of various paths forward. By defaultLitElement
will do the work of ensuring the ShadyCSS is applied to components in a polyfilled context. ShadyCSS does the work to emulate shadow DOM based style encapsulation in browsers that do not yet support the specification natively, a list of browsers that grows shorter every day with the sun settings on IE11 and pre-Edgium Edge. It does so at the intersection of correctness and performance by writing a single scoped version of the styles targeted to the component in question into the global scope. This goes a long way towards maintaining the "styles scoped to element" contract of Shadow DOM based styles; however, it comes with two main trade-offs. The first involves not specifically addressing the "protected from external selectors" contract, which means that ALL styles from outside of your shadow DOM will have the ability to leak into your component. The second is more particularly troubling in the context of ourportal-destination
definition, the styles applied to all instances of custom element's shadow DOM will have to be the same by default.
In that each piece of projected content over the lifecycle of an applicationcould be deserving of custom styling this can prove tricky in the context we've been working so far where we apply our content directly to the<portal-entrace/>
element:
<portal-entrancedestination="style-demo"><style>button{background:red;}</style><h1>Send This Content</h1><p>Hello world! From my-element ${this.counter}</p><button@click=${this.increase}>+1</button></portal-entrance>
For the<style/>
s defined in this context to apply to theportal-destination
element, we need to do work over the top of theLitElement
implementation to correctly scope this content via the ShadyCSS polyfill. What's more, the<style/>
element would need to not be inside of theshadowRoot
of a parent element at runtime to ensure it will not be consumed for by that parent element as if those styles were meant for it. The most direct way to overcome this issue is to wrap the content that we'd like to send over the portal in a custom element:
<portal-entrancedestination="destination"><content-to-be-ported-element></content-to-be-ported-element></portal-entrance>
However, the restrictions this places on potential use are quite prohibitive:
- Don't put
<style/>
elements directly into you<portal-entrance/>
's light DOM. - When attempting to send style data across the portal, wrap it in an element, a la
<content-to-be-ported-element/>
. - Manually wire state management between the parent element and the
<content-to-be-ported-element/>
. - Etc.
While every well-defined piece of code requires a list of things you can't do with it, I feel this is a bridge too far. We should be able to dial these back a bit and allow us to ship this functionality with a little more flexibility. The main thing we are looking to address here is the ability to place<style/>
elements directly into the<portal-entrance/>
element and have those styles apply to the<portal-destination/>
element to which they are sent. Luckily, whether you are using@webcomponents/webcomponentsjs/webcomponents-bundle.js
or its slimmed down younger sibling@webcomponents/webcomponentsjs/webcomponents-loader.js
to ensure cross-browser support they will each ensure that browsers without native shadow DOM support are delivered the ShadyCSS polyfill.
The ShadyCSS polyfill supplies an API by which templates and styles can be prepared to approximate the encapsulation of the content in our similarly polyfilled shadow root from the rest of the document. We can use it to do additional work of it over the top of what is provided byLitElement
in order to ensure the same treatment of<style/>
content sent over our portal. The process involves these steps:
- Ensure that either the polyfill is not present or that it is not presently being used.
- Create a template to prepare the styles in.
- Gather all of the
<style/>
tags that will be direct children on the<portal-destination/>
element. Capture both their style text (innerHTML
) for scoping and append the nodes to the template created above for preparing the DOM. - Early return if no
<style/>
tags have been found. - Use ShadyCSS to scope the gathered CSS text and prepare the template to apply those scoped styles.
- Forward the non-HTMLStyleElement elements to be appended to the
<portal-destination/>
element.
This looks like the following in code:
getpreparedProjected(){if(!this.projected)return[];if(window.ShadyCSS===undefined||window.ShadyCSS.nativeShadow){returnthis.projected;}letstyles=[];lettemplate=document.createElement('template');this.projected.filter(el=>el.constructor===HTMLStyleElement).map((s)=>{styles.push(s.innerHTML);template.appendChild(s);});if(styles.length===0){returnthis.projected;}template.innerHTML=stylesHTML.join('');window.ShadyCSS.ScopingShim.prepareAdoptedCssText(styles,this.localName);window.ShadyCSS.prepareTemplate(template,this.localName);window.ShadyCSS.styleElement(this);returnthis.projected.filter(el=>el.constructor!==HTMLStyleElement);}
This means that our usage caveats are much more acceptable:
- You cannot have
<style/>
element openly available for consumption by a parent component at runtime. - Only
<style/>
elements that are direct children will apply to the light DOM content of an "entrance". <style/>
elements directly in the<portal-entrance/>
light DOM will apply to all<portal-destintion/>
elements and their content, regardless ofname
.
Note: I would expect styles applied this way torougly follow the cascade (it seems they get added in reverse cascade 😖, but the current state of the ShadyCSS library does not agree. I've created the followingissue to hopefully bring some agreement between the expectation and the reality here.
With these alterations, our family of portal elements is now ready for delivery cross-browser no matter the level of support those browsers have for the Shadow DOM specification. This capability came with some active trade-offs, but as they are directly in line with those that come with the ShadyCSS polyfill itself, which means they will hopefully be familiar to those working with other web components and shadow DOM tools.
When you bring this all together in an updated version of ourMenu Populates Content Populates Menu Example from the previous article, it looks like the following in all its cross-browser supporting glory:
no-shadow
on the<html-include-with-anchors/>
elements, there is some sort of timing issue in upgrading there<portal-entrance/>
elements therein that I'm gonna have to follow up on separately.
From this baseline, we can now focus on rounding out some of the capabilities of our portal.
Declarative API
The ability to dynamically track the attributes of an element without any special APIs for setup is certainly one of the clearest wins of the custom element specification. Through the use of the staticobservedAttributes
array and the associatedattributeChangedCallback
we are able to take fine-grained control over how our components react to changes declared directly in the markup describing them. That means the following code allows our newly defined custom element to react to changes in the value of thecustom-attribute
attribute and store that value as a local property.
classDeclarativeElementextendsHTMLElement{staticobservedAttributes=['custom-attribute'];attributeChangedCallback(name,oldValue,newValue){switch(name){case'custom-attribute':this.customProperty=newValue;break;}}}
Others have previously pointed out that managingALL of your attributes and their relationship to properties in this manner can be quite tiresome, and I would agree. Not having to manually wire everything you want to track in the HTML of your custom element to related properties one at a time is a great reason to work with libraries and tooling when developing web components. Luckily, we're already committed to usingLitElement
as a base class which helps us setup this relationship via itsstatic get properties()
API. Let's take a look at how the above is achieved therein:
classDeclarativeElementextendsLitElement{staticproperties={customProperty:{type:String,attribute:'custom-attribute'}}}
Notice the change fromHTMLElement
toLitElement
for our class extension. That change gives us access to a static properties getter that will outline the attributes we want to hear about changes to, and we receive anextended list of options with which you can outline the relationship between the attributes and their associated properties. For our<portal-entrace/>
element, we can outline a more declarative API, like so:
classPortalEntranceextendsLitElement{staticproperties={destination:{type:String},manual:{type:Boolean},open:{type:Boolean,reflect:true},order:{type:Number},}}
Adding a property in this way to aLitElement
based custom element also means that changes to these properties will automatically kick off the update lifecycle of the component. In the case that these properties are used in building the DOM representation of your element, this is super helpful. However, being that none of these properties need to trigger a new render there are a couple of paths to optimizing reactive management of these attributes. We could extend these definitions to includehasChanged() { return false; }
and prevent that entirely. Or, we could separately use theshouldUpdate
lifecycle method to prevent that holistically across the component. Further, knowing that there is zero processing that goes into understanding our element's template of<slot @slotchange=${this.shouldProjectSlot}></slot>
, we can rely onlit-html
, the renderer underlyingLitElement
, to efficiently discover that there are no DOM changes to be made after any of those changes and not worry about extended configuration at all. So many options towards ensuring a more performant application! To ensure that our<portal-entrance/>
elements are rendered once and then not worried about again, we'll pair theshouldUpdate
and thefirstUpdated
lifecycle methods like so:
shouldRender(){return!this._hasRendered;}firstUpdated(){this._hasRendered=true;}
Here, our first update occurs unimpeded but by settingthis.shouldRender() = false
as part of that first update, no further updates to the rendered shadow DOM are made.
Right about now you might be asking, "If they don't trigger a render, what do these propertieseven do?", and with good reason! First, let's remember that all of the DOM related to our portal is supplied as light DOM, and we use the<slot/>
element in our template to listen to changes in that content for sending across the portal, which means internally we only need to render once, as shown above. When changes in the light DOM content occur, a call toshouldProjectSlot()
will be made, which is where our component decides what to do with the DOM provided:
shouldProjectSlot(){if(!this.open){if(!this.manual){this.open=true;}}elseif(this.manual){this.projectSlot();}}
The most important thing to take away from this transaction is that whenmanual === true
andopen === true
theprojectSlot()
method will be called directly allowing content placed into<portal-entrance/>
to be streamed across the portal. Otherwise, whenmanual === false
,open
is set totrue
, which relies on the following getter/setter pair:
getopen(){returnthis._open;}setopen(open){if(this.open===open)return;this._open=open;if(open){this.setAttribute('open','');this.projectSlot();}else{this.removeAttribute('open');this.close();}}
Within this setter we eventually make that call toprojectSlot()
in this context as well, we just make a short detour to maintain a representative state on the way there. This allows us to worry about the fewest number of entries into the projection functionality as possible while also aligning the internal API of the<portal-entrace/>
element with that available from the outside.
We'll match this with declarative updates to the API of our<portal-destintion/>
element as well. These additions will leave our static properties getter looking like the following:
classPortalDestinationextendsLitElement{staticproperties={name:{type:String},projected:{type:Array},multiple:{type:Boolean},announces:{type:Boolean},projecting:{type:Boolean}}}
Much of these additions will be discussed in greater depth along with the features they add below, but, before we move on, notice theprojecting
property. We'll use this in conjunction with theprojecting
attribute as a hook for styling this component when content is being projected into it. This being purely representational of internal state, it will be helpful to prevent this from being changed from the outside. While techniques like the use of underscore prefixed ornew Symbol()
based property names can support this sort of security, we can also manage this reality by only offering a setter for this value:
setprojecting(projecting){projecting=this.projected.length>0;if(projecting){this.setAttribute('projecting','');}else{this.removeAttribute('projecting');}}
Here we receive an incoming value and simply throw it away. At this time, I don't see needing this property for anything other than the styling hook, so we don't even need to cache it internally. In theupdated()
lifecycle method we'll usethis.projecting = 'update';
to initiate this functionality, and the setter will manage the presence of theprojecting
attribute.
With our declarative API prepared, controlling theopen
state anddestination
of a<portal-entrance/>
becomes very straight forward. See it in action below:
Multiple Entrances
Now that we are more practiced on delivering the API for our portal in a declarative manner, doing so for additional features will hopefully become less and less daunting. One piece of functionality that we've previously discussed supporting and that can benefit from a declarative API is the ability to project content from more than one<portal-entrance />
into a single<portal-destination/>
; another feature originally outlined by thePortal Vue project. We can power this with the addition of amultiple
attribute to our<portal-destination/>
element, as well as anorder
attribute to our<portal-entrance/>
element. Usage might appear like this following:
<portal-entrancedestination="mutliple"order="1"><h1>Second</h1></portal-entrance><portal-entrancedestination="mutliple"order="0"><h1>First</h1></portal-entrance><portal-destinationmultiplename="mutliple"></portal-destination>
In the above example, both of the<h1/>
elements will be sent to the<portal-destination/>
and due to the presence ofmultiple
, both will be displayed therein. However, because of the values in theorder
attributes for those<portal-entrance/>
elements, the first<h1/>
will be displayed second, and the second<h1/>
will be displayed first. To make this possible, we've added theorder
attribute to the static properties getter in our "entrance" element:
order:{type:Number}
With that attribute surfaced at the API level, it will then be available for delivering to our "destination" element via theportal-open
:
projectSlot(){letcontent=this.portalContent;if(!content.length)return;this.dispatchEvent(createEvent('portal-open',{destination:this.destination,content:content,entrance:this,order:this.order||0,}));}
On the "destination" side, there will be a good bit more that needs to change to support this addition. Before we get into those, we'll need to add the new attribute to its properties getter:
multiple:{type:Boolean}
Once again, this allows us to receive changes to this attribute via theattributeChangedCallback
thatLitElement
connects directly to a matching property. With that available in our custom element, we'll then be able to use it to make decisions on how to respond to the various events that are being listened for. Specifically, we'll change theupdatePortalContent
method from being a catch-all for the most recently opened/closed<portal-entrance/>
element to a gate for managing content differently depending on the value ofmultiple
:
updatePortalContent(e){this.multiple?this.portalContentFromMultiple(e):this.portalContentFromOne(e);}
That simple, right? Riiight.
To support both of these code paths, we'll create an intermediary map to cache the available content before flattening it into an array of arrays for pushing into our template. This means we'll create anew Map()
that will be keyed by the actual<portal-entrance/>
elements from which the content is delivered. The values will be structured as an object with both the received content, as well as the order value from the "entrance" element:
{portal-element=>{content:node[],order:number,}}
We'll build this data in response to theportal-open
event via the following method:
cacheByOriginOnOpen(e){if(e.type!=='portal-open')return;this.projectedByOrigin.set(e.detail.entrance,{content:e.detail.content,order:e.detail.order,});}
We'll use this map in themultiple === false
path of ourupdatePortalContent
functionality to decide whether the "destination" is currently receiving content from an "entrance" and to close that entrance before applying new content to the destination:
portalContentFromOne(e){if(this.projectedByOrigin.size){this.projectedByOrigin.keys().next().value.open=false;}this.cacheByOriginOnOpen(e);this.projected=e.detail.content||[];}
And, on themultiple === true
path, the map will power our ability to sort the content by theorder
attribute delivered from the "entrance" and flatten the map into our expectedprojected
property:
portalContentFromMultiple(e){this.cacheByOriginOnOpen(e);constbatchProjected=Array.from(this.projectedByOrigin.values());batchProjected=batchProjected.sort((a,b)=>a.order-b.order).reduce((acc,projection)=>{acc.push(projection.content);returnacc;},[]);this.projected=batchProjected;}
Whenportal-close
is dispatched, we'll use this structure to ensure only the content in question is being returned to the closing<portal-entrance/>
element while also removing that element from the local cache before updating the portal content once again:
closePortal=(e)=>{if(!this.confirmDestination(e))return;this.returnProjectedWhenManual(e);this.projectedByOrigin.delete(e.detail.entrance);this.updatePortalContent(e);}returnProjectedWhenManual({detail:{manual,entrance}}){if(!manual)return;constprojected=this.projectedByOrigin.get(entrance);if(!projected)return;projected.content.map(el=>entrance.appendChild(el));}
In an actual application, this could exhibit a list of items for multiple selected with the<portal-destination/>
playing the role of confirmation UI, allowing it to be located anywhere on the page. In the following example, the "selected" list will appear directly next to the ten options. However, in the DOM, the two lists are in completely different branches:
Mirrored Listening
Up to this point we've relied on our<portal-destination/>
elements being live and named when our<portal-entrance/>
elements come knocking with theirportal-open
events. Paired with our recent addition of themanual
attribute outlined above, this seems like a fairly complete API relationship between the two elements. However, what if our "entrance" is ready toopen before out "destination" is ready toreceive? Whether through general runtime realities or as applied consciously when taking full control of your application's load process, it is feasible that you will run into a context where you intend for a<portal-destination/>
to be lying in wait when youopen
a<portal-entrace/>
and it's just not there. To support this, let's add some functionality to "announce" the presence or a change of name in our "destination" element. It's a great addition to the declarative API of our elements, we can do so, while also making it opt-in, by adding anannounces
attribute to our<portal-destination/>
element. While we're at it, let's also make thename
attribute reflect so that any changes we make to that value imperatively will be represented in the rendered DOM.
name:{type:String,reflect:true,},announces:{type:Boolean,}
WithLitElement
we have a couple of options as to where we'd like to react to changes in our properties. In this case, we can get all of the flexibility that we'll need by relying on theupdated
lifecycle method. There we will receive a map keyed by values that have changed pointing to the previous value of those properties. This will allow us to test for changes to eitherannounces
orname
withchanges.has()
, like so:
updated(changes){if(changes.has('announces')){this.shouldAnnounce();}elseif(changes.has('name')&&typeofchanges.get('name')!=='undefined'){this.announce();}this.projecting='update';}
In the case of changes toname
, when the value is being changed (not when being set initially fromundefined
) we'll immediately make a call toannounce()
the presence of the<portal-destination/>
element. When it is the value ofannounces
that has changed we'll make a call toshouldAnnounce()
which confirmsannounces === true
before callingannounce()
. This path is also added to theconnectedCallback
so that when the element is rejoining the DOM it will also announce itself when configured to do so.
announce(){this.dispatchEvent(createEvent('portal-destination',{name:this.name,}));}
As you can see, theannounce
method is powered again by Custom Events, this time theportal-destination
event. On the<portal-entrance/>
side we'll listen for that event, using a listener attached to thedocument
and thecapture
phase of that event so that it can respond accordingly with as little interference as possible:
connectedCallback(){super.connectedCallback();document.addEventListener('portal-destination',this.destinationAvailable,true);}disconnectedCallback(){super.disconnectedCallback();document.removeEventListener('portal-destination',this.destinationAvailable,true);this.open=false;}destinationAvailable=(e)=>{if(e.detail.name===this.destination){this.shouldProjectSlot();}}
And now we're listening on both sides of the portal. Our already thorough API is even more complete and we've further expanded the ways we can leverage our component manages content and the way it can display throughout our application. While it's not always easy to anticipate how realities of the loading process will affect the performance of our applications, in the following demo I've artificially delayed thecustomElements.define()
call for the<portal-destination/>
element so that you can experience what this enables. Run the demo with the console open to follow along on the delayed timing:
Even More Styles
With the support for style application that we added as part of our cross-browser coverage, we now have a lot of control over how we style the content that we're sending over the portal. Styles contained within child components of our<portal-entrance/>
s forwarded to our<portal-destination/>
.<style/>
tag children of those "entrances" are also forwarded to their assigned "destination", assuming that when ShadyCSS is required those elements are added after the<portal-entrance/>
's parent element's shadow DOM was initially polyfilled. However, when working with custom elements and shadow DOM we are offered an even wider array of possibilities to style our DOM.
There are some newer ways like working withConstructible Stylesheets, and the number of immediate performance benefits they bring. In concert with theadoptedStyleSheet
API, they also open anexpanded set of possibilities when working within predefined style systems. There are also more common concepts that need to be addressed like CSS Custom Properties.
The way that they offer a style bridge into the shadow DOM of a custom element is really powerful. However, when physically moving DOM from one part of the DOM tree to another it can take that content out of the cascade which those custom properties rely on to be applied appropriately. With those custom properties being difficult to acquire without previous knowledge of their presence, it is tricky to find productive/performant ways to move those properties along with the content that is being sent across the portal. These concepts and more being ripe for research, a follow-up article specifically covering style acquisition and application seems appropriate, even before this one is even done.
But, now what?
Beyond simply porting our<portal-entrance/>
and<portal-destination/>
elements to extending theLitElement
base class, we've already done so much:
- prepared the elements for delivery cross-browser
- surfaced a declarative API
- added support to display content from
multiple
"entrances" in a single "destination" - created a bi-directional relationship between the two elements so that the portal can open regardless of which is ready first
But, there is still so much to do!
Even before getting into the experimental work around supporting a more rich style application ecosystem, the most important next step is the addition of testing. Even just developing the demos for this article I found a number of edge cases that will need to be fully covered to call these components "production ready". I've done my best to fill in the holes as I wrote, but I'm sure there are things that I've missed and updates not appropriately reflected in this article. Focusing on the integration point between these two elements, there is much to be done in order to ensure future additions and refactoring do not affect the functionality we've worked on so far negatively. To that end, I will be spending some quality time withTesting Workflow for Web Components before getting back to you all with even more explorations on the other side of the portal. Try not to close the "entrance" while I'm gone.
Top comments(3)

- LocationMinneapolis, MN USA
- EducationMA History
- WorkFounder and CTO at Logical Phase Systems
- Joined
Brilliant WJ! Great to see expanded demonstrations using LitElement.

- LocationGöteborg
- Joined
Is this published anywhere?

The code itself, no. Feel free to take it straight from the example, if you'd like...
The concept is built intoopensource.adobe.com/spectrum-web-... which might be of interest.
At current, I'm investigating a future version of this leveraging<dialog>
and thepopup
attribute and hopefully CSS Anchoring...once it's fully up to snuff, hopefully I'll have time to write about it as well!
For further actions, you may consider blocking this person and/orreporting abuse