A practical guide to using shadow DOM
What the heck is “shadow DOM”?
There have been hundreds of attempts at explaining shadow DOM, and it still remains confusing for many developers. I find that the MDN page for shadow DOM does a pretty decent job of explaining it, so I’d recommend reading it if you haven’t already. (Fun fact: When I started writing this post, MDN didn’t even document the declarative syntax)
Shadow DOM enables you to attach a DOM tree to an element, and have the internals of this tree hidden from JavaScript and CSS running in the page.
There is one frequently-misunderstood bit that I’d like to clear up though. Regular DOM content (commonly known as “light DOM”) can be slotted inside shadow DOM. The shadow trees can be thought of as donuts, where the holes may be filled by light DOM content. This light DOM content continues to stay in the light DOM (rather than being somehow “transported” into the shadow DOM). The content from shadow DOM and light DOM is then interleaved to create the final DOM tree.
Visualization showing how light DOM and shadow DOM can be interleaved to create a combined final tree. Show separate trees
To really drive this point home, I recommend enabling “user-agent shadow DOM” in devtools. This will allow you to peek inside the shadow DOM of built-in DOM elements (such as <details>
). The content that you pass into these elements stays in the light DOM, and only gets “revealed” by the user-agent shadow DOM.
“Imperative” vs “declarative”
Previously, we had to rely on client-side JavaScript to imperatively attach a shadow root to an element.
element.attachShadow({ mode: "open" })
element.shadowRoot.innerHTML = `<p>Hello from the shadows!</p>`
This JavaScript requirement made shadow DOM a non-starter for the vast majority of use cases. I used it almost exclusively for optional enhancements that are not critical to the initial page load. Even then, the developer ergonomics of imperatively attaching a shadow root and populating its content felt awkward at best. And of course, it simply does not work when JavaScript isn’t available.
With declarative shadow DOM, we can now express our shadow trees right in our HTML! The browser will attach the shadow root as soon as it encounters it, before any JavaScript even loads.
<div>
<template shadowrootmode="open">
<p>This is "hidden" inside shadow DOM</p>
<slot></slot>
</template>
<p>This is the regular ol' content</p>
</div>
The <template>
will also be automatically removed from the DOM, so when you the inspect the element in dev tools, it feels as if the shadow root came pre-attached with the element.
(And yes, shadow DOM can be used with <div>
, as well as some other built-in elements, including <body>
, <p>
, and <span>
.)
A couple more important things to note:
- Declarative shadow DOM is an HTML parser feature. It will not work if you try to set it inside
innerHTML
, for example. To create shadow trees from within JavaScript, the imperativeattachShadow
method should be used instead (at least until we get new DOM APIs likesetHTML
). - Be careful about newlines. Writing
<template>
in its own line makes sense for readability, but you might inadvertently end up slotting whitespace into the default<slot>
(and thereby overriding the fallback slot content) if you have nothing else in the light DOM.
Use cases
I often get asked “Why should I care about shadow DOM?” Well, here’s a non-exhaustive list of ways in which shadow DOM can come in handy today:
- invisible hover triangles
- invisible rectangles for increasing tap target size
- ”handles” for resizing and dragging dialogs/panels/columns
- wrapper elements for virtual scrolling/windowing
- sibling elements for focus trapping
- ”overlapping”/duplicated elements (such as GitHub’s code view)
- decorative hover effects and animations for landing pages
- visually-hidden text for improved accessibility
- visual hints for drag-and-drop interfaces
- 3D objects and complex SVGs
- out-of-order HTML streaming
- changing or enforcing source order of slotted content
- ”passthrough”/noop shadow roots to access shadowDOM-specific features (such as
slotchange
)
In almost all of the above cases, most of the critical content stays in the light DOM, and shadow DOM is only used for enhancing the baseline experience and/or hiding unimportant implementation details. Even if some of these things could be achieved using light DOM only, I find it valuable to hide the messy bits inside shadow DOM. It greatly improves the debugging experience, by reducing the amount of div soup generated in the light DOM tree. It also allows me to opaquely make changes within shadow DOM without affecting any of the “outside” DOM selectors and such.
There are also some scenarios where complete encapsulation may be desired, in which case the content can be fully rendered (rather than slotted) inside shadow DOM:
- isolated iframe-like views (e.g. interactive demos, like the one above)
- third-party widgets and ads
- microfrontends
Style scoping and cascading
It is very important to understand how styles work in shadow DOM.
Perhaps the easiest thing to understand is that styles inside a shadow tree do not affect anything outside it, and styles outside the shadow tree do not affect anything inside it. This is a stronger form of scoped styling (more accurately called “style encapsulation”), in that it allows you to write very weak selectors, at the expense of giving up the ability to use external styles.
<head>
<style>
p {
color: blue;
}
</style>
</head>
<body>
<div>
<template shadowrootmode="open">
<style>
p {
color: red;
}
</style>
<p>This is red.</p>
<slot></slot>
</template>
<p>This is blue.</p>
</div>
</body>
What may not be obvious is that shadow DOM styles are cascaded before document styles. This means that :host
styles will always lose to any “outside” styles, regardless of specificity (unless you use !important
).
<head>
<style>
:where(div) {
color: blue;
}
</style>
</head>
<body>
<div>
<template shadowrootmode="open">
<style>
:host {
color: red;
}
</style>
<p>This is also blue!</p>
<slot></slot>
</template>
<p>This is blue.</p>
</div>
</body>
Lastly, CSS shadow parts can be used to selectively allow external styles into shadow DOM. I won’t bother explaining it, because there are many existing guides on the topic, and frankly, I’ve never needed to use it. Custom properties and slots can go pretty far already.
Shadow DOM is still problematic
I find it very strange that we didn’t have a declarative syntax for using shadow DOM until just now. Better late than never, I suppose. Still, it’s just a syntax. It does not really fix any of the larger problems with shadow DOM, most importantly around styling, accessibility, and form participation. In fact, declarative shadow DOM introduces another problem of having to repeat the <template>
for every single instance.
I’ve been able to work around the main styling issues by manually injecting my global stylesheets into all shadow trees. However, this is not a proper solution and only works for my own components (unless I rely on JavaScript, which… no thanks).
<template shadowrootmode="open">
<link rel="stylesheet" href="utilities.css" />
<span class="visually-hidden">Utility classes work!</span>
<slot></slot>
</template>
To get around the other issues, here’s my rule of thumb: do not put any interactive form controls or semantically-important elements inside shadow DOM. As long as these stay in the light DOM and are slotted into shadow DOM, we can avoid most of the shadow nonsense. As a bonus, common patterns like document.activeElement
and :has(:focus-visible)
will work correctly, and the debugging experience will also be improved.
// ❌<my-input></my-input>
// ✅<my-input> <input /></my-input>
// ❌<my-button></my-button>
// ✅<my-button> <button>…</button></my-button>
Usage within frameworks
Most of us are probably building websites using some kind of framework, or at least a templating engine. I think frameworks play a crucial role in using shadow DOM at scale, particularly because web components are not components. These frameworks can effectively serve as a polyfill for some of the missing web component APIs.
Broadly speaking, there are two kinds of web frameworks, and declarative shadow DOM interacts differently with them.
- Server-first frameworks (e.g. Astro, WebC, Enhance): Since these frameworks are primarily concerned with producing HTML on the server, declarative shadow DOM generally works fine here (as long as you manage to produce the correct HTML).
- Client-first frameworks (e.g. (P)react, Vue, Svelte): These frameworks support server-side rendering, but they tend to “hydrate” (recreate) the server-generated markup on the client, which does not play well with declarative shadow DOM. It’s possible to work around though, by using imperative code on the client.
I’ll have a more detailed follow-up post demonstrating how declarative shadow DOM can be used within various frameworks, so stay tuned. 👀
You may have noticed I didn’t mention custom elements anywhere in this article. That’s not an accident. Custom elements and shadow DOM are independent web component APIs that do not always need to be used together. Custom elements may be completely unnecessary when using a client-side JavaScript framework which already knows how to manage its own rendering lifecycle. In such cases, it totally makes sense to use shadow DOM without custom elements.