This lesson is the direct capstone of the template and slot lessons earlier in this module ā custom elements are the missing piece that turns 'inert cloneable markup' and 'content projection' into an actual, real, reusable, tag-like component.
1Custom Elements: A Genuinely New HTML Tag
customElements.define('user-card', UserCard), where UserCard extends HTMLElement, registers a genuinely new HTML tag ā <user-card> ā backed by that class, with the browser automatically invoking lifecycle callbacks like connectedCallback() (when an instance is inserted into the document) and attributeChangedCallback() (when a watched attribute changes) as the component's actual usage in the page changes over time.
This is a real, native mechanism, not a simulation ā <user-card> becomes a genuine HTML element usable anywhere standard markup is written, with the same fundamental status as any built-in tag like <button> or <video>.
2Shadow DOM Encapsulation, Populated By template And slot
this.attachShadow({ mode: "open" }) gives the component its own isolated shadow tree, protecting the outer page's CSS from accidentally reaching in and styling internal component markup, and (by default) keeping the component's own internal styles scoped and non-leaking. This encapsulation is what makes a component's internals genuinely private implementation detail, safely reusable across projects without CSS collisions.
Combining directly with the previous two lessons in this module: connectedCallback() typically clones a <template>'s content directly into that shadow root ā real, parser-validated structural markup rather than JS-built strings ā and that template's own markup includes <slot> elements, letting the component accept and correctly position caller-supplied light-DOM content exactly as covered in the Slot lesson.
3Declarative Shadow DOM: Server-Rendered Components, No JS Wait
A meaningful limitation of the JS-only approach above: the shadow root's content doesn't exist until JavaScript loads and connectedCallback() actually runs ā for a server-rendered page, this means a custom element renders empty (or with fallback light-DOM content only) until JS execution catches up, a real, visible delay for anything meant to appear immediately.
Declarative Shadow DOM solves this directly: a <template shadowrootmode="open"> placed as a direct child of the custom element in the *server-rendered HTML itself* is automatically, natively converted into that element's actual shadow root by the browser's HTML parser ā no JavaScript execution required for the shadow content to exist and render at first paint. The custom element's JS class can still attach behavior afterward, but the structural, visual content is already there the instant the HTML parses.
4The Real Payoff: Genuine Framework Independence
Because every piece of this pattern ā customElements.define(), Shadow DOM, <template>, <slot>, and Declarative Shadow DOM ā is a native browser API rather than a framework-specific abstraction, a component built this way works identically whether it's used inside a React application's JSX, a Vue template, a plain server-rendered HTML page, or any future framework not yet invented, with zero adaptation required.
This is a genuinely distinct property from a component built in any single framework's own component model, which is inherently tied to that framework's runtime and rendering approach ā a native custom element is, by construction, exactly as portable as HTML itself, which is the entire, durable value proposition of building this way.
5Step-by-Step Breakdown
A Real Component, Without A Framework. <template> gave you inert, cloneable markup. <slot> gave you a way to accept caller content. Custom elements are the piece that ties both into an actual reusable, encapsulated, tag-like component ā usable in any project, with no framework runtime required.
A Custom Element Is A Class Registered As A New Tag. customElements.define('user-card', UserCardClass) registers a class extending HTMLElement as a genuinely new HTML tag ā <user-card> ā with its own lifecycle callbacks (connectedCallback, disconnectedCallback, attributeChangedCallback) the browser invokes automatically as instances are created, inserted, removed, or have watched attributes change.
Registering A Custom Element. What does customElements.define('user-card', UserCard) actually do?
- āRegisters UserCard as the implementation behind a genuinely new HTML tag, <user-card>
- āCreates a reusable CSS class named user-card
- āOnly works if a specific JS framework is also loaded on the page
Shadow DOM Encapsulates The Component's Internal Structure And Style. this.attachShadow({ mode: 'open' }) gives an element its own isolated internal DOM tree ā a shadow root ā where the component's internal markup lives, protected from the outer page's CSS reaching in unexpectedly, and (by default) keeping the component's own internal styles from leaking out onto the rest of the page.
What Shadow DOM Isolates. What does attaching a Shadow DOM to a custom element primarily protect against?
- āThe outer page's CSS accidentally styling internal component markup, and the component's own styles leaking onto the page
- āThe component making unauthorized network requests
- āJavaScript errors occurring anywhere on the page
template + slot: The Structural Content Of A Well-Built Component. Combining everything from this module: connectedCallback clones a <template>'s content into the shadow root, and that template's own markup includes <slot> elements ā giving the component real, parser-validated internal structure that also accepts and correctly positions caller-supplied light-DOM content.
Combining template, slot, And Custom Elements. What role does <template> typically play in a well-built custom element like this?
- āIt provides the source markup cloned into the component's shadow root, including its slots
- āIt has no particular relationship to custom elements
- āIt replaces the need for a JavaScript class entirely
Declarative Shadow DOM: Server-Rendering A Component's Shadow Root. Declarative Shadow DOM lets a component's shadow root be expressed directly in server-rendered HTML ā a <template shadowrootmode="open"> as a direct child of the custom element ā so the shadow content exists immediately on first paint, without waiting for JavaScript to load and run attachShadow() and populate it, meaningfully improving perceived load performance for server-rendered pages using custom elements.
Declarative Shadow DOM's Purpose. What problem does Declarative Shadow DOM solve for server-rendered pages using custom elements?
- āThe component's shadow DOM content is present at first paint, without waiting for JavaScript to run and build it
- āIt eliminates the need for a custom element's JavaScript class entirely
- āIt's unrelated to server-rendering concerns
A Genuinely Framework-Independent, Portable Component. Because customElements.define(), Shadow DOM, <template>, and <slot> are all native browser APIs, a custom element built entirely from them works identically inside a React app, a Vue app, plain server-rendered HTML, or no framework at all ā a real, meaningful portability property no framework-specific component format can offer.
The Portability Payoff. Why can a custom element built entirely with native browser APIs be used identically inside a React app, a Vue app, or plain HTML?
- āIt's implemented with native browser APIs, not any specific framework's component model, so it works wherever HTML/DOM works
- āReact and Vue both have identical, special built-in support specifically for this
- āIt's mostly coincidental and works only in specific framework versions
Reusable HTML Components Mastered. You now know how to build a genuinely reusable, encapsulated, framework-independent component using custom elements, Shadow DOM, template cloning, and slot content projection ā plus server-rendering its shadow root declaratively for first-paint content.
Mark A Reusable Component. A data-component attribute names a template as a reusable, script-driven component.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Shadow DOM Encapsulation Doesn't Exempt A Component's Internal Markup From Accessibility Requirements
Content inside a shadow root still needs correct semantic structure, labeling, and ARIA where applicable ā encapsulation is a styling/structural boundary, not an accessibility exemption.
SEO Implications
- 1
Declarative Shadow DOM Content Is Present In The Server-Rendered HTML And Crawlable Immediately
Unlike JS-populated shadow roots, which may not be visible to crawlers that don't fully execute JavaScript, Declarative Shadow DOM content is part of the actual server response, straightforwardly crawlable like any other HTML.
Best Practices
Use Declarative Shadow DOM For Any Custom Element Rendered Server-Side, Not Only JS-Populated Shadow Roots
This closes the first-paint gap where a server-rendered custom element would otherwise appear empty until JavaScript loads and executes.
Keep A Component's Public API ā Its Attributes And Named Slots ā Deliberately Small And Well-Documented
Exactly like designing any reusable abstraction, a minimal, clear contract between the component and its callers is what actually makes it reusable across projects and teams.
Frequent Bugs
A server-rendered page shows custom elements as empty or unstyled for a noticeable moment before JavaScript finishes loading.
Use Declarative Shadow DOM (<template shadowrootmode="open">) so the shadow content is present in the initial HTML response, not dependent on JS execution.
Caller-supplied content passed into a custom element doesn't appear anywhere inside it.
Verify the component's shadow root actually contains a matching <slot> (named or default) for that content, as covered in the Slot Element lesson.
Real-World Examples
A Complete, Server-Renderable Reusable Component
A <user-card> component combining Declarative Shadow DOM, slots, and a JS class for any additional interactive behavior.
<user-card>
<template shadowrootmode="open">
<style>.card{border:1px solid #ccc;padding:1rem;border-radius:8px;}</style>
<div class="card">
<slot name="avatar"><img src="/default-avatar.png" alt=""></slot>
<slot name="name"></slot>
</div>
</template>
<span slot="name">Ada Lovelace</span>
</user-card>
<script>
customElements.define("user-card", class extends HTMLElement {
connectedCallback() {
// Shadow root already exists declaratively ā just add behavior
this.shadowRoot.addEventListener("click", () => this.classList.toggle("expanded"));
}
});
</script>