Html Templates And Shadow Dom
The template element holds markup the browser ignores until you clone it. Shadow DOM gives a component its own isolated DOM tree where styles cannot leak in or out.
After registering a custom element, I had a <menu-page> tag the browser recognized. It rendered nothing. The element existed in the DOM as an empty shell. That is where HTML templates come in -- and where I first ran into the CSS leak problem that Shadow DOM exists to solve.
The Essentials
<template>is parsed but not rendered: The browser reads the template element and builds the DOM for its contents, but does not display anything. The markup sits in memory, waiting to be cloned.template.content.cloneNode(true): Creates a deep copy of the template's contents. Pass the clone toappendChild- never the template itself, which is read-only.- Without Shadow DOM, template styles are global: A
<style>inside a template that changesh2color changes everyh2on the page once the template is cloned into the main DOM. this.attachShadow({ mode: 'open' }): Creates an isolated DOM tree inside the element. Styles declared inside apply only there. Styles from outside do not apply inside.mode: 'open'vsmode: 'closed': Open means external JavaScript can access the shadow DOM viaelement.shadowRoot. Closed means it cannot - the shadow root is only accessible from within the class.
The template Element
The <template> element is a way to define markup in an HTML file that the browser holds in reserve:
<template id="menu-page-template">
<section id="menu">
<h2>Our Menu</h2>
<ul class="menu-list"></ul>
</section>
</template>The browser parses this, validates the HTML, and makes it available through the DOM API. But nothing in the template is rendered - no boxes are laid out, no images are fetched, no scripts are run.
Cloning and Using the Template
Inside a custom element's connectedCallback, clone the template and append it:
connectedCallback() {
const template = document.getElementById('menu-page-template');
const content = template.content.cloneNode(true);
this.appendChild(content);
}cloneNode(true) is a deep clone - it copies the entire subtree, not just the top-level element. This matters when the template has nested elements, which it almost always does.
The clone goes into the element's DOM. The template remains unchanged and available to clone again for any other instance.
Why the constructor Does Not Work Here
Calling appendChild inside the constructor throws an error: "The result must not have children." The spec forbids manipulating an element's children during construction because at that point the element may not be connected to any document.
connectedCallback is the right place. It runs when the element is actually inserted into the DOM, which is when it is safe to build out its contents.
The CSS Leak Problem
Once a template is cloned into the main document, its <style> declarations become part of the global stylesheet. This example breaks every h2 on the page:
<template id="menu-page-template">
<style>
h2 { color: violet; }
</style>
<h2>Our Menu</h2>
</template>Cloning this template and appending it to the DOM applies color: violet to all h2 elements - inside the component and outside it. This is the CSS isolation problem Shadow DOM solves.
Shadow DOM
Shadow DOM attaches a private DOM tree to a custom element. Anything appended to the shadow root is separate from the main document:
class MenuPage extends HTMLElement {
constructor() {
super();
this.root = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const template = document.getElementById('menu-page-template');
const content = template.content.cloneNode(true);
this.root.appendChild(content); // append to shadow root, not `this`
}
}Two changes from the non-shadow version:
attachShadowin the constructor stores the shadow root asthis.root.appendChildtargetsthis.rootinstead ofthis.
Now the template's styles are scoped to the shadow root. The h2 { color: violet } inside the component does not reach the h2 in the rest of the page.
Open vs Closed Mode
mode: 'open' means external code can read the shadow DOM:
const menuPage = document.querySelector('menu-page');
menuPage.shadowRoot; // returns the shadow rootmode: 'closed' returns null for shadowRoot - the shadow DOM is only reachable from inside the class. For most application code, open mode is sufficient and easier to debug in browser DevTools.
Slots
Slots are placeholders inside a component's template. They let the component consumer inject content from the outside:
<template id="card-template">
<div class="card">
<slot name="title"></slot>
<slot></slot>
</div>
</template><my-card>
<h2 slot="title">Coffee</h2>
<p>Dark roast, single origin.</p>
</my-card>Named slots match by the slot attribute. The unnamed slot catches anything without a slot attribute. In React terms, this is the children prop - a way for the parent to pass markup into the component.
Further Reading and Watching
- MDN: Using templates and slots - Full guide to the template element, cloneNode, and slot usage.
- MDN: Using shadow DOM - Covers attachShadow, open/closed modes, and CSS scoping behavior.
Video:
- Web Components Crash Course by Traversy Media. Practical walkthrough of templates and Shadow DOM with real examples.