The life of a Web Component - Reversing the Shadow DOM visibility
Shadow DOM displays its contents by default while hidding the original tag children. Can this be reversed? What kind of possibilities does it offer us if it does?
The other day I was thinking about what do HTML tags give us out of the box and made this list:
- Hierarchy
<luke-im-your-father>
<nooo-thats-impossible></nooo-thats-impossible>
</luke-im-your-father>
- Sequence
<p>I’d use one for parts</p>
<p>If I ever have twins</p>
- Meaning
<q cite="https://en.wikipedia.org/wiki/Meaning_(philosophy)">
A relationship between two sorts of things:
signs and the kinds of things they intend, express or signify
</q>
- Functionality
<video controls width="250">
<source src="/media/cc0-videos/flower.webm"
type="video/webm">
<source src="/media/cc0-videos/flower.mp4"
type="video/mp4">
Sorry, your browser doesn't support embedded videos.
</video>
This list is by no means exhaustive. There are likely many more things that HTML tags give us (like headaches, reactjs, and some money at the end of the month if we are lucky).
With the Shadow DOM it is possible to compose all of the features above and hide them away inside custom tags.
I want to use Shadow DOM like a rug that I can sweep tags under it. A particular DOM root that cannot be seen and that is under the control of a tag.
- Encapsulation
<nevermind-the-backend-this-is-where-the-dragons-are>
<!-- there is nothing to see here because this tag has a Shadow root -->
</nevermind-the-backend-this-is-where-the-dragons-are>
This DOM root inception made me wonder: "What is the default visibility behavior of the Shadow DOM? could it be reversed?"
Hmm, that sounds stupid, but how would it work?
When a Web Component has no shadow root, it will happily render all its children:
<html><body>
<simple-web-component>
<p>Hello world! I am not inside the shadow root.</p>
</simple-web-component>
<script>
customElements.define("simple-web-component",
// What follows is the the <simple-web-component> code:
class extends HTMLElement {
initialize() {
// Nothing for now... just a simple component
}
// Call initialize when added to a parent node
connectedCallback() { this.initialize(); }
});
</script>
</body></html>
The code above creates the functionality for the <simple-web-component>
tag. Which gets rendered as expected:
However, this is not how it behaves when a shadow root is added to it.
Adding a bare shadow root
The code inside the <script>
tag needs to be slightly adjusted to add a shadow root.
customElements.define("simple-web-component",
// The <simple-web-component> code:
class extends HTMLElement {
root = this.attachShadow({mode: 'open'})
initialize() {
const p = document.createElement("p");
p.textContent = "In the shadow!";
this.root.append(p);
}
// Call initialize when added to a parent node
connectedCallback() { this.initialize(); }
});
The <simple-web-component>
class now has a root
attribute set with its shadow root. Then a simple <p>In the shadow!</p>
is placed inside it by the initialize()
function.
This simple shadow root will affect the display of the component.
The previous <p>Hello world! I am not inside the shadow root.</p>
will no longer be shown since it is outside the shadow root, and now only the shadow root contents are being displayed.
However, the inspector tools shows how the tag is really juggling the shadow dom:
Looking at the above hierarchy it is worth noting that the original <p>Hello World!..</p>
is still there, but why is it not visible?
Once a shadow root is added to a tag, it becomes the thing that will get rendered. Shadow DOM is the authority, everything else is discarded, and only the stuff inside the shadow root gets displayed.
Why?
Because the idea was that whatever children a custom tag would have could get rendered with ninja tricks inside the shadow. Thus giving the impression that the custom tag was getting its input from its visible children and then performing hidden layout and style foojitsu on that input (such tricks would occur inside the shadow dom - hidden from the written HTML).
Nobody cares. In fact, let me revert this logic.
You wouldn't download a car
The first step to revert the Shadow DOM is to copy all the custom components' children into its shadow root.
This will allow HTML to be rendered as expected while maintaining the Shadow DOM's encapsulation features.
Luckily the Web Components spec provides just the feature for this, through the <slot>
tag.
I'm such a <slot>
This tag is used mainly in HTML <template>
's. It is the way that <template>
's have to tell which parts can be set by outsiders. Typically <slot>
's have a name attribute to be used as the reference to where the content will go. (You can see an example of it here, but it is not necessary, I won't be using it.)
The cool thing is that if there is no name
specified on the <slot>
, then it becomes the default <slot>
where all unspecified content will go in.
This is perfect. It means that if a <slot>
is placed inside the shadow root (without a name="..."
attribute), then all of the custom element children will automatically be copied into it.
<html><body>
<simple-web-component>
<p>Hello world! I am not inside the shadow root.</p>
</simple-web-component>
<script>
customElements.define("simple-web-component",
// The <simple-web-component> code:
class extends HTMLElement {
root = this.attachShadow({mode: 'open'})
initialize() {
const p = document.createElement("p");
p.textContent = "In the shadow!";
this.root.append(
p,
document.createElement("slot")
);
}
// Call initialize when added to a parent node
connectedCallback() { this.initialize(); }
});
</script>
</body></html>
The Shadow Root above will contain a single <slot>
tag. This makes the <p>Hello world! I am not inside the shadow root.</p>
show up when the tag is rendered.
Don't ask, don't tell
The second step to revert the Shadow DOM display is to only show the children of the element.
Hide everything that is outside the <slot>
. Only the tag's original children must be visible, while all the other shadow root contents must be hidden.
To achieve it, the following CSS is added to the Shadow DOM:
:not(slot) {
display: none;
}
The common practice is to create a <style>
tag, place the above content inside it and then append the <style>
tag to the Shadow DOM:
initialize() {
const p = document.createElement("p");
p.textContent = "In the shadow!";
const style = document.createElement("style");
style.textContent = `
:not(slot) {
display: none;
}`;
this.root.append(
style,
p,
document.createElement("slot")
);
}
And just like that, the actual behavior of the Shadow DOM is reversed. The Shadow DOM contents are hidden by default, while the original custom tag children get rendered by default.
No "In the shadow!" text is visible.
Conclusion
It is possible to sweep all kinds of useless trash under the Shadow DOM.
However, it might require reversing the Shadow DOM's visibility (which is rendered by default).
To achieve it, a full copy of the custom element original children is made into the Shadow DOM (through the default <slot>
tag). The next step is to hide everything inside the Shadow DOM that does not belong to the original children.
With these two moves, the element's original children become visible while leaving all the other shadow DOM contents invisible.
This post is Part 3 of a series I am writing called "The Life of a Web Component".
The previous parts are: