The Shadow DOM is in the front
Last updated
Due to its name, I used to think the Shadow DOM was this hard-to-use, murky thing that caused weird things to happen to the UI. So I mistrusted it—I thought it was something to be used only with great caution.
But recently something clicked. It isn't somewhere behind in the shadows.
It's in the front: the Shadow DOM is a facade that obscures the Plain DOM.
How the Shadow DOM works
The Shadow DOM is typically used hand-in-hand with Custom Elements.
You can make any custom HTML element (with any look & feel) in plain JavaScript using
customElements.define("my-custom-element", MyElementClass)
—as long as there's a hyphen in the name.
And in your custom element class, if you call this.attachShadow({ mode: "open" })
, you get this.shadowRoot
:
a shadow root node you can render to—just like any other element.
Consider this silly element, which dances when you click it:
Rendered | HTML |
---|---|
|
<dancing-man
left="superb"
right="fantastic"
></dancing-man>
|
This little guy is ~60 lines of CSS and ~70 lines of JS (full code here):
const dancers = [
["(•_•)", "<) )╯", " / \\ "],
["(•_•)", "\\( (>", " / \\ "],
["(•_•)", "╰( (>", " / \\ "],
["(•_•)", "<) )/", " / \\ "],
];
class DancingManElement extends HTMLElement {
// Call attributeChangedCallback() when any of these attributes change
static observedAttributes = ["left", "right"];
constructor() {
super();
// Local state (our stuff is prefixed with _)
this._leftSide = true;
this._clicks = 0;
// mode: "open" = the ShadowRoot instance will be at this.shadowRoot
this.attachShadow({ mode: "open" });
// We're a button, so respond to clicks:
this.addEventListener("click", (e) => {
e.preventDefault();
this._clicks += 1;
this._leftSide = !this._leftSide;
this._rerender();
});
// A fun font would be nice to have, so load it here
const font = new FontFace(
"Comic Neue",
"url(https://fonts.gstatic.com/s/comicneue/v8/4UaErEJDsxBrF37olUeD_xHM8pxULilENlY.woff2)",
{
style: "normal",
weight: "700",
},
);
font.load().then((loaded_face) => {
// In order to use the font in our custom element, we must add it to the document
document.fonts.add(loaded_face);
});
}
connectedCallback() {
// When mounted to the document, we rerender
this._rerender();
}
attributeChangedCallback(name, oldValue, newValue) {
// Each attribute we observe changes the content
this._rerender();
}
_rerender() {
// Default message values:
const leftMessage = this.getAttribute("left") || "groovy";
const rightMessage = this.getAttribute("right") || "dig it";
const dancer = dancers[this._clicks % dancers.length].join("\n");
// Simplistic render to the Shadow DOM
this.shadowRoot.innerHTML = `
<style>
/* Shine animation for when the element is (re)rendered */
@keyframes shine {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
.dancing-man {
/* 3 even columns: left dancer right */
display: inline-grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
gap: 12px;
/* Looks like a badge/button */
cursor: pointer;
border: 2px black solid;
border-radius: 50%;
background: linear-gradient(30deg, #e0e0e0, #f9f9f9, #e0e0e0);
padding: 8px 16px;
color: #000000;
/* An offset container for the shine animation */
position: relative;
overflow: hidden;
}
.dancing-man::before {
/* The shine animation gradient */
content: "";
position: absolute;
width: 100%;
height: 100%;
transform: translateX(-100%);
background: linear-gradient(
30deg,
transparent 20%,
transparent 40%,
rgb(255, 255, 255, 0.4) 50%,
rgb(255, 255, 255, 0.4) 55%,
transparent 70%,
transparent 100%
);
animation: shine 400ms 100ms 1 ease-in;
}
.dancing-man:active {
/* Darker colors while pressed */
background: linear-gradient(30deg, #c4c4c4, #f9f9f9, #c4c4c4);
border-color: #404040;
}
.message_left,
.message_right {
font-family: 'Comic Neue';
font-weight: 700;
font-style: normal;
visibility: hidden;
}
.dancing-man.right .message_right,
.dancing-man.left .message_left {
visibility: visible;
}
</style>
<button class="dancing-man ${this._leftSide ? "left" : "right"}">
<div class="message_left">${leftMessage}</div>
<pre class="dancer">${dancer}</pre>
<div class="message_right">${rightMessage}</div>
</button>
`;
}
}
customElements.define("dancing-man", DancingManElement);
These Custom Elements are more flexible than components that just render to the Normal DOM.
With the Shadow DOM, they can act like a sticker: behaving and displaying exactly the same on any website, regardless of how that website is styled via CSS.
The Shadow DOM does not inherit any style from the document .
This makes it so flexible! Instead of looking like the document's style, the HTML and CSS in this custom element's Shadow DOM is viewed in a style-less facade that's placed on top of the Normal DOM.
This gives you full control over how your component looks and feels.
Just drop it in
To demonstrate, go and open up any website, paste that code in the developer tools, and edit the HTML to include a <dancing-man>
of your own. You can just drop it in and forget about it!
What else can it do?
The Shadow DOM also lets a Custom Element choose to render its children using the <slot>
element.
Our <dancing-man>
element does not use a <slot>
, so it hides all the children it contains:
Rendered | HTML |
---|---|
|
<dancing-man
left="what will"
right="this do"
>
<span>
Look, I've got children!
</span>
</dancing-man>
|
Here, the user doesn't see that <span>Look, I've got children!</span>
child at all. It's completely obscured by the
Shadow DOM facade.
This allows our Custom Element to just do what it does well: show a dancing man.
It's neither constrained by the look and feel of the document nor obligated to show any children given to it.
This is even true for the accessibility tree! (which is what screen readers and other assistive devices use to help blind/low vision folks understand the document)
But what if we want to show these children?
If we were to change our code
slightly,
we could use the <slot>
element and slot
attribute to let our dancing-man dance with its children.
Rendered | HTML |
---|---|
ok
|
<dancing-man-slots>
<span slot="left">
just <strong>dance</strong>
</span>
<span slot="right">
it'll be <code>ok</code>
</span>
</dancing-man-slots>
|
See how the font is the same as the font in our document? And how the <code>
in the child looks like how it does on
this page?
These <slot>
elements act like literal slots in a facade, revealing the Plain DOM underneath. Like a window showing
the inside of a building behind its facade.
The change to our
code was to use <slot name="left">
and <slot name="right">
instead of attributes to
place child elements in specific slots within the Shadow DOM:
<button class="dancing-man ${this._leftSide ? "left" : "right"}">
- <div class="message_left">${leftMessage}</div>
+ <slot name="left" class="message_left">groovy</slot>
<pre class="dancer">${dancer}</pre>
- <div class="message_right">${rightMessage}</div>
+ <slot name="right" class="message_right">dig it</slot>
</button>
The name
attribute of <slot name="left">groovy</slot>
maps to the slot
attribute on our <span slot="left">just <strong>dance</strong></span>
child.
If no slot="left"
attribute was present, then the fallback content of groovy
would be used.
And if you want to show all of the unmapped slots, you can include a <slot>default content</slot>
element in the
Shadow DOM that doesn't have a name
attribute.
In conclusion
The Shadow DOM is a misleading name. It's not this scary, hidden thing lurking in the shadows.
It's a facade (that can have slots) that sits between the user and the Plain DOM.