The template element
<template> holds markup that is parsed but not rendered. Nothing inside it is displayed, no image inside it is downloaded, no script inside it runs. It sits in the document as a stencil until JavaScript clones it.
That is the difference from a hidden <div>: a hidden div is a real part of the page that happens to be invisible, so its images still load and its ids still collide.
<template id="card-tpl">
<article class="card">
<h3 class="title"></h3>
<p class="body"></p>
<a class="more" href="#">Read more</a>
</article>
</template>
<div id="list"></div>
Cloning it
tpl.contentis a DocumentFragment — the children of the template.cloneNode(true)copies it deeply. Clone every time; do not appendtpl.contentitself or you empty the stencil.- Appending a fragment inserts its children and leaves the fragment empty. Build the whole list in one fragment and append once, so the browser lays out once.
- Use
textContent, neverinnerHTML, for values that came from anywhere else — that is the difference between a card and a cross-site scripting hole.
const tpl = document.getElementById("card-tpl");
const list = document.getElementById("list");
const doors = [
{ title: "MDF painted", body: "Painted on site with the wall.", href: "/doors/painted/" },
{ title: "Veneer", body: "The grain carries on across the opening.", href: "/doors/veneer/" },
];
for (const d of doors) {
const node = tpl.content.cloneNode(true); // true: deep copy
node.querySelector(".title").textContent = d.title;
node.querySelector(".body").textContent = d.body;
node.querySelector(".more").href = d.href;
list.append(node);
}
Once you clone a template twice, any id inside it exists twice. Use classes inside templates and add ids afterwards if you really need them.
A custom element
customElements.define teaches the browser a new tag. It is a class extending HTMLElement with a few lifecycle methods. No build step, no library — and it works in any framework, because to React or Next.js it is just an unknown tag the browser happens to understand.
class DoorCounter extends HTMLElement {
static observedAttributes = ["count"];
connectedCallback() { // in the document
this.render();
this.addEventListener("click", () => {
this.setAttribute("count", Number(this.getAttribute("count") || 0) + 1);
});
}
attributeChangedCallback() { // one of observedAttributes changed
this.render();
}
render() {
this.textContent = (this.getAttribute("count") || 0) + " doors";
}
}
customElements.define("door-counter", DoorCounter);
<door-counter count="3"></door-counter>
The rules
- The name must contain a hyphen:
door-counter, nevercounter. That is what keeps your names from colliding with future HTML. - Lifecycle:
constructor(no DOM work here),connectedCallback(added to the document — do the work here),disconnectedCallback(removed — clean up listeners and timers),attributeChangedCallback(one ofobservedAttributeschanged). - An element may be in the document before its script has run. It upgrades when defined, which is why
connectedCallbackand not the constructor is where rendering belongs. customElements.whenDefined("door-counter")resolves once it is registered.
Shadow DOM and slots
attachShadow gives an element a private tree. Styles inside it cannot leak out and page styles cannot reach in — real encapsulation, and also real isolation, which is sometimes exactly what you do not want.
A <slot> is a hole in that private tree where the element's own children are shown.
class InfoCard extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
.card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; }
h3 { margin: 0 0 6px; font: 600 18px system-ui; }
</style>
<div class="card">
<h3><slot name="title">Untitled</slot></h3>
<slot></slot>
</div>`;
}
}
customElements.define("info-card", InfoCard);
<info-card>
<span slot="title">MDF painted</span>
<p>Painted on site with the wall.</p>
</info-card>
A named slot takes children carrying slot="name". The unnamed slot takes everything else. The text inside a <slot> is the fallback when nothing is passed.
When to reach for this
- A repeated block driven by data, on a page with no framework:
<template>pluscloneNode. This is the common case. - A widget that has to work across several codebases or frameworks: a custom element.
- A widget whose styling must not be touched by the host page: shadow DOM — accepting that theming it then needs custom properties or
::part. - A whole application: a framework. Custom elements are an element, not an architecture.