---
title: "29 — Web Components — Reusable Elements the Platform Already Ships"
uid: web-components
tags: ["custom-elements", "web-components", "shadow-dom", "templates", "platform", "roadmap:frontend"]
excerpt: "A Web Component isn't one feature — it's three platform features composing into one self-contained element that works in React, Vue, plain HTML, or nothing at all."
date: 2026-08-12T18:35:07+0000
source: https://www.aveshina.my.id/en/blog/web-components
---

"A React component, but without React" was my Web Components assumption, and it hid the platform mechanics. The model that stuck: **a Web Component isn't one feature, it's three platform features composed into a single self-contained element.** [1] Once I could see the three pieces separately, the appeal clicked — a component that works in React, Vue, plain HTML, or nothing at all, because it isn't a library abstraction. It's a browser element, the same kind as <button>.

The three pieces are **Custom Elements** (define your own tag and wire behavior to it), **Shadow DOM** (a private, isolated subtree of the DOM — the browser's in-memory tree of the page — so the component's markup and styles can't leak out, or be reached from outside), and **HTML <template>** (an inert blueprint you clone instead of parsing markup by hand). None of them is a framework. They're shipped by the browser. Here's how they stack into one component:

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Three platform features composing one Web Component. On the left, three stacked input boxes: HTML template — an inert blueprint you clone, Custom Element — a JS class that defines the tag and behavior, Shadow DOM — an isolated subtree that hides the markup and styles. Three arrows converge to a single box on the right labelled my-button, a self-contained element usable anywhere.">
  <defs>
    <marker id="wcarrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- three inputs -->
    <rect x="40" y="30" width="300" height="60" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="190" y="56" font-size="13" font-weight="700" fill="#422006" text-anchor="middle">HTML &lt;template&gt;</text>
    <text x="190" y="74" font-size="10.5" fill="#475569" text-anchor="middle">an inert blueprint — clone it, don't parse it</text>

    <rect x="40" y="110" width="300" height="60" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="190" y="136" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">Custom Element</text>
    <text x="190" y="154" font-size="10.5" fill="#475569" text-anchor="middle">a JS class that defines the tag + behavior</text>

    <rect x="40" y="190" width="300" height="60" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="190" y="216" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Shadow DOM</text>
    <text x="190" y="234" font-size="10.5" fill="#475569" text-anchor="middle">isolated subtree — styles and DOM stay inside</text>

    <!-- arrows converging -->
    <path d="M340,60 C440,60 470,140 530,140" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#wcarrow)"/>
    <path d="M340,140 L530,140" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#wcarrow)"/>
    <path d="M340,220 C440,220 470,140 530,140" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#wcarrow)"/>

    <!-- output -->
    <rect x="540" y="110" width="160" height="60" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="620" y="136" font-size="13" font-weight="700" fill="#052e16" text-anchor="middle" font-family="ui-monospace, monospace">&lt;my-button&gt;</text>
    <text x="620" y="154" font-size="10.5" fill="#475569" text-anchor="middle">one self-contained element</text>

    <text x="370" y="290" font-size="11" fill="#64748b" text-anchor="middle" font-style="italic">three platform features — one reusable element, any framework (or none)</text>
  </g>
</svg>
```

The payoff of seeing them separately is that each piece solves a distinct problem the others don't, and I no longer conflated them. I'll take them one at a time, then show where they meet.

## Custom Elements: defining your own tag

The first piece is the one I'd been faking for years with a class="button" div. A **Custom Element** lets me define a brand-new HTML tag with its own behavior, registered with the browser so it's treated like any built-in element [2].

The mechanics are small: write a JavaScript class extending HTMLElement, then call customElements.define('my-tag', MyTag). The hyphen in the name is mandatory — it's how the browser tells custom tags apart from future built-ins. The class gets **lifecycle callbacks** — connectedCallback fires when the element enters the DOM, disconnectedCallback when it leaves, attributeChangedCallback when a watched attribute changes — which is the same idea as React's mount/unmount/update lifecycle, but as a native browser hook, not a library abstraction.

The shift in framing: I'm not building a component _that the framework renders._ I'm extending the browser's own element vocabulary. Once defined, <my-button> is just a tag — it works in plain HTML, in a Vue template, inside React JSX, or rendered by a server. That portability is the whole reason the spec exists.

## Shadow DOM: the isolation boundary

The second piece is what makes a component actually _self-contained_ — and it's the one that took longest to click, because it's invisible. **Shadow DOM** lets an element host a private DOM subtree that's sealed off from the main document [3]. The page can't see in with its CSS; the component's styles can't leak out.

The thing I had to straighten out: Shadow DOM isn't a visual thing. It's a DOM _boundary._ Styles in the main page's stylesheet don't reach the shadow tree (so a global button { color: red } won't repaint my component's buttons), and styles defined inside the shadow tree don't escape (so my component's .active class won't accidentally style the rest of the page). Before this, "component CSS leaks" was a problem I solved with naming conventions — BEM (a naming scheme), CSS Modules, scoped selectors. Shadow DOM solves it at the platform level: the boundary just won't let styles cross it [4].

The same isolation applies to the DOM itself. document.querySelectorAll('div') won't find divs living inside a shadow tree — you have to pierce into it deliberately. That's why this isn't "another div on the page." It's a sealed subtree. For something like a date picker or a modal that shouldn't care what page it lands on, that seal is the entire value proposition.

## HTML Templates: the inert blueprint

The third piece is the one I'd been doing the dumb way. **HTML <template>** is an element whose content is parsed but not rendered — a reusable chunk of markup held in reserve, waiting to be cloned [5]. Inside a <template>, <img> tags don't load, <script> tags don't run, nested elements don't exist on the page. It's pure structure, parked.

The payoff is in the word _cloned._ When my Custom Element needs to populate its shadow root, I reach for template.content.cloneNode(true) instead of concatenating HTML strings or hand-building nodes with createElement. The browser already parsed the markup once; cloning is cheap, and there's no risk of accidentally triggering network requests or scripts the way innerHTML can. For a component rendered many times, that blueprint-then-clone pattern is the efficient path.

<template> also pairs with **slots** — <slot name="header"> — which are the contract for letting the outside page inject content into the component's shadow tree [5]. Slots are how a component stays encapsulated (wrapped up so the outside can't reach in) while still being composable: the markup lives in the page, lands in the right slot, and the component decides where it renders.

## Where the three meet

The composition is what makes it worth writing the notes down. None of the three features alone is a component:

- Custom Elements alone give you a tag with behavior, but its markup still lives in the open DOM — styles leak, selectors collide.
- Shadow DOM alone gives you isolation, but you'd be filling it by hand every time.
- <template> alone is a handy markup stash, but it doesn't define an element or isolate anything.

Stacked, they cover each other's gaps. The Custom Element class, in its connectedCallback, attaches a shadow root and clones a <template> into it [1]. From outside, the page sees a single tag with attributes and slots; from inside, the component sees its own sealed DOM with its own styles. That's a self-contained element — reusable across projects and frameworks because nothing about it depends on any framework's runtime.

## How I use this

The practical note I keep is a rule of thumb, not a mandate. Web Components aren't a replacement for React or Vue in my stack — those still do the app-level job well. Where the model pays off is at the edges: a self-contained widget (a date picker, a video player, a design-system button) that has to survive being dropped into pages I don't control — different frameworks, different global stylesheets, different teams. For that, a Custom Element with a shadow root is the one artifact that won't bleed or be bled on.

The habit these notes left me with is to ask, before reaching for a framework component, one question: _does this need to work outside my framework?_ If yes, the browser already ships the three pieces I need. If no, a framework component is fine — but it's worth knowing the native option exists, because the whole point of Web Components is that it's there whether I use a library or not.

## References

[1] Mozilla, "Web Components," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/Web_Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components)

[2] Mozilla, "Using custom elements," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements)

[3] Mozilla, "Using shadow DOM," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM)

[4] "Web Components," webcomponents.org, 2024. [Online]. Available: [https://webcomponents.org/](https://webcomponents.org/)

[5] Mozilla, "Using templates and slots," MDN Web Docs, 2024. [Online]. Available: [https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots)

```quiz
Q: A Web Component is best described as…
- a single new browser API for building components
- three platform features — Custom Elements, Shadow DOM, and templates — composed into one element
- a lightweight alternative to the DOM
correct: 1
explain: "Web Components" isn't one API. It's the combination of Custom Elements (the tag), Shadow DOM (isolation), and HTML <template> (the blueprint) working together.

Q: Why must a custom element's name contain a hyphen (e.g. <my-button>)?
- it's a style convention, optional in practice
- it's how the browser distinguishes custom elements from current and future built-in elements
- it's required only for elements that use Shadow DOM
correct: 1
explain: The hyphen is mandatory — the browser uses it to tell custom tags apart from standard ones and any built-ins added later.

Q: A global stylesheet rule `button { color: red }` is added to the page. What happens to <button>s living inside a component's shadow DOM?
- they turn red — global styles always win
- they're unaffected — the shadow boundary blocks outer styles from crossing in
correct: 1
explain: The shadow root is a style boundary. Outer document selectors don't reach into the shadow tree, so the component's buttons keep their own styles.

Q: What does cloning a <template> give you over setting innerHTML?
- nothing — they're equivalent
- pre-parsed, inert markup you can insert cheaply, without triggering scripts or image loads
correct: 1
explain: A <template>'s content is parsed once but never executed. cloneNode(true) copies that inert structure, so <script> and <img> inside it don't fire until the clone is connected.

Q: The reason Web Components work across frameworks is that…
- every framework ships a Web Components adapter
- they're implemented by the browser as native elements, not by any library's runtime
correct: 1
explain: Custom Elements, Shadow DOM, and templates are platform features. A registered <my-button> is a real browser element, so React, Vue, or plain HTML all see the same tag — no framework runtime required.
```
