Up front: I tested the behavior in this post in Chromium; the Vue/Nuxt parts were tested with Vue's SSR and this site's Nuxt setup. Other frameworks may behave differently. If I've gotten something wrong, corrections welcome.
Intro
Work with web components for a while and you'll hit this: the component "flashes" on screen.
The reason is simple: Shadow DOM can only be created with JavaScript. The HTML arrives and paints first, and at that point the component is empty and unstyled; only after JavaScript downloads and runs does the component "grow" its contents. If JavaScript fails to load or is turned off, the component disappears entirely. SSR with frameworks like Nuxt or Next is the same story: the server can output the component's tag, but not the Shadow DOM inside it.
Declarative Shadow DOM fixes exactly this. It's one of the few items in my CSS & HTML cheat sheet that's already 🟢 stable, yet rarely used.
First, here's what your browser supports:
Your browser's support
Declarative Shadow DOMDetecting (requires JavaScript)
1. The problem: what happens with JavaScript off?
Both iframes below start with JavaScript disabled (blocked with the sandbox attribute). The left uses the traditional approach, the right uses the declarative one, and the card's HTML content is identical:
Live demo: both iframes start with JavaScript disabled
This is an interactive demo. Try it on the web page.
The left side is just two unstyled bits of text run together, because the shadow root was never created. The right side shows the full card, styles included. Only after checking "Allow the iframes to run JavaScript" do they look the same.
This is the "flash" people see on a slow connection, and what they see when JavaScript fails to load.
2. The syntax: write the shadow root in HTML
The traditional way creates the shadow root in the component's JavaScript:
class MyCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" }).innerHTML = `
<style>.card { border: 2px solid #6042a0; }</style>
<div class="card"><slot name="title"></slot></div>
`;
}
}
The declarative way writes it straight into HTML, wrapped in <template shadowrootmode>:
<my-card>
<template shadowrootmode="open">
<style>.card { border: 2px solid #6042a0; }</style>
<div class="card"><slot name="title"></slot></div>
</template>
<span slot="title">Three days in Kyoto</span>
</my-card>
When the browser parses the HTML and sees <template shadowrootmode>, it turns it into the parent element's shadow root, and the <template> itself disappears. A few things I confirmed in testing:
shadowRootexists before the component is even defined withcustomElements.define().- Styles are still encapsulated: styles inside the shadow don't leak out.
- With
shadowrootmode="closed", readingel.shadowRootfrom outside givesnull, just like a closed root created in JavaScript.
The <template> also takes these attributes:
| Attribute | Effect |
|---|---|
shadowrootdelegatesfocus | Clicking the component itself hands focus to the first focusable element inside; handy for custom form controls |
shadowrootserializable | Lets getHTML({ serializableShadowRoots: true }) output the shadow root back as HTML |
shadowrootclonable | Copies the shadow root along with cloneNode() |
Support starts at Chrome / Edge 111, Firefox 123 and Safari 16.4, and it's been Baseline since February 2024, so it's safe to use.
3. The key step: components should take over, not rebuild
This is where converting an existing web component to Declarative Shadow DOM most often goes wrong.
Many components call attachShadow() first thing in the constructor. In my tests, if the element already has a declarative shadow root and attachShadow() is called with the same mode, it doesn't throw; it returns the same shadow root, but empties it (with a different mode, it throws NotSupportedError). The content the server worked to render gets wiped by the component itself.
The two cards below have identical HTML, both drawn first by Declarative Shadow DOM, and the component's JavaScript deliberately arrives 1.5 seconds late:
Live demo: both cards have identical HTML; the component's JavaScript arrives 1.5 seconds later
This is an interactive demo. Try it on the web page.
The left component calls attachShadow() right away, so the content is wiped and redrawn later, with a flash in between. The right one checks for an existing shadow root first and simply takes it over, so nothing on screen changes.
The right way looks like this:
class MyCard extends HTMLElement {
constructor() {
super();
// The server already built it with <template shadowrootmode>, so take it over
const root = this.attachInternals().shadowRoot;
if (!root) {
// Only draw it yourself if there isn't one (e.g. components created in the browser)
this.attachShadow({ mode: "open" }).innerHTML = `...`;
}
}
}
I use attachInternals().shadowRoot rather than this.shadowRoot because the former also returns closed shadow roots, which I confirmed in testing too.
4. Dynamic insertion: innerHTML won't do it
If content is inserted with JavaScript, note that innerHTML doesn't process Declarative Shadow DOM; <template shadowrootmode> just becomes an ordinary template:
| Method | Creates the shadow root? |
|---|---|
el.innerHTML = "..." | No |
el.setHTMLUnsafe("...") | Yes, but only for elements inside the string; not the element you call it on |
Document.parseHTMLUnsafe("...") | Yes |
I found the setHTMLUnsafe() limit while testing: if the string starts directly with <template shadowrootmode> in the hope of making el itself the host, it won't work. An element inside the string has to be the host, as in <my-card><template shadowrootmode>…</template></my-card>.
5. Be careful in Vue/Nuxt
I assumed I could just write <template shadowrootmode> in a Vue template. Testing turned up three problems:
- Vue swallows
<slot>: Vue treats<slot>as its own slot syntax, so SSR outputs Vue slot markers instead of native<slot>elements, and the shadow root ends up with no slots. <style>doesn't match up: the SSR output includes<style>, but the client-side template compile ignores it, which is another mismatch.- Hydration doesn't match: the browser already turned the
<template>into a shadow root while parsing, but Vue on the client still expects a<template>there, so you get hydration mismatch warnings and an extra<template>inserted into the page.
The more reliable approach is to keep Vue entirely unaware of the shadow root: the Vue template contains only the component and its light DOM, and after SSR produces the full HTML, you insert <template shadowrootmode> right after the component's opening tag. In Nuxt, Nitro's render:html hook does this:
// server/plugins/declarative-shadow-dom.ts
const MY_CARD_SHADOW = `<template shadowrootmode="open">
<style>.card { border: 2px solid #6042a0; }</style>
<div class="card"><slot name="title"></slot></div>
</template>`;
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("render:html", (html) => {
html.body = html.body.map((chunk) =>
chunk.replace(/<my-card(\s[^>]*)?>/g, (tag) => tag + MY_CARD_SHADOW),
);
});
});
I verified this approach with Vue's SSR: no hydration warnings, the component took over the shadow root, it was styled with JavaScript off, and Vue's reactivity kept working. I also confirmed on this site's Nuxt setup that the render:html hook can rewrite html.body.
One caution: editing the HTML string with a regular expression only suits simple tags. With more components, you may need proper HTML parsing, or a tool built for web component SSR such as Lit SSR.
6. Trade-offs
Declarative Shadow DOM isn't free:
- Styles are duplicated: every component instance carries its own
<style>, so 20 cards on a page means 20 copies. You can switch to sharedadoptedStyleSheetsafter the component takes over, or accept the cost. - HTML gets bigger: the shadow root's content is output into every instance.
- Frameworks won't do it for you: as the previous section showed, frameworks like Vue and React need extra handling.
So my recommendation: Declarative Shadow DOM is worth it for components above the fold, with important content, or that should work without JavaScript. Purely interactive components that aren't visible on first load can stay as they are.
Conclusion
In a few lines:
- Traditional web components must wait for JavaScript, so they flash during SSR and vanish if JavaScript fails.
- Declarative Shadow DOM writes the shadow root into HTML with
<template shadowrootmode>, and it's Baseline 2024. - Components should check
attachInternals().shadowRootfirst and take it over, rather than wiping it withattachShadow(). - For dynamic insertion, use
setHTMLUnsafe()orparseHTMLUnsafe();innerHTMLwon't work. - In Vue/Nuxt, don't write it in templates; insert it after SSR output instead.
Writing these posts, I keep finding that the old idea of "make the HTML work first, then enhance with JavaScript" has never gone out of date. Declarative Shadow DOM finally lets web components do that too, which makes a real difference for people on slow networks, older devices, or assistive technology.
Do you use web components in your projects? I'd love to hear about it.
Related reading
- Make Screen Readers Speak: Replacing aria-live with ariaNotify()
- How to Write a Dialog Today: From showModal() to command and closedby
- Modern CSS & HTML Cheat Sheet: 115 Features, Support & What to Try Next