Build reusable, framework-agnostic UI components using native Web Components. Covers Custom Elements, Shadow DOM, HTML templates, slots, lifecycle callbacks, and the patterns that make Web Components work alongside React, Vue, and Angular.
Web component architecture is the practice of building reusable, self-contained UI elements using browser-native primitives: Custom Elements, Shadow DOM, and HTML Templates. It matters when you need UI parts that work across frameworks, avoid framework lock-in, or require strong encapsulation of markup, style, and behavior. Unlike framework-specific component models, web components execute in the browser without a runtime wrapper, meaning they can be used in React, Vue, Angular, or plain HTML with identical semantics.
This page is for frontend engineers who are tired of component APIs that change every major framework release, or who need to ship UI that must work in environments where a particular framework isn’t guaranteed. It is also for engineers debugging why a component’s styles leak, why event bubbling feels wrong, or why a custom element refuses to upgrade. The patterns below are platform primitives; they exist in every modern browser shipping ES2022 or later.
Every web component starts with customElements.define(). This registers a new tag name with the browser’s custom element registry. The tag name must include a hyphen to avoid collision with existing HTML elements.
customElements.define('app-header', class extends HTMLElement {
connectedCallback() {
this.innerHTML = `<h1>Hello, world</h1>`;
}
});
Once defined, <app-header> can be used anywhere HTML is valid. The browser upgrades the element automatically when the class is defined; no build step is required for the registration itself.
For components that need internal structure, attachShadow() creates a shadow root. This provides a separate DOM tree where styles and scripts do not leak out—and vice versa.
customElements.define('app-header', class extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>h1 { color: navy; }</style><h1>Hello, world</h1>`;
}
});
The mode argument accepts 'open' (accessible via element.shadowRoot) or 'closed' (accessible only via element.createShadowRoot() in older APIs, but generally avoided). Open mode is the default recommendation unless you have a specific isolation requirement.
The <template> element holds HTML that is not rendered until explicitly instantiated. This is useful for defining complex markup that may be cloned multiple times, or for keeping the initial page payload small.
<template id="card-template">
<article>
<h3 class="title"></h3>
<div class="body"></div>
<footer class="meta"></footer>
</article>
</template>
customElements.define('product-card', class extends HTMLElement {
connectedCallback() {
const template = document.getElementById('card-template').content;
const clone = template.cloneNode(true);
clone.querySelector('.title').textContent = this.getAttribute('name') || 'Untitled';
clone.querySelector('.body').innerHTML = this.innerHTML;
this.attachShadow({ mode: 'open' }).appendChild(clone);
}
});
Cloning a template preserves the structure but allows each instance to have unique content. The shadow root acts as the insertion point, so styles defined inside the component do not affect the host document, and document styles do not cascade into the component unless @import or :host selectors are used.
:hostStyle isolation is one of the primary reasons engineers reach for web components. By default, CSS rules from the light DOM do not reach into the shadow tree, and rules inside the shadow tree do not affect the light DOM. This eliminates the need for CSS-in-JS scoping strategies or naming conventions like BEM.
Inside the shadow DOM, you can target the host element using the :host pseudo-class. This is how a component exposes its public API through styling.
/* Inside the component’s style block or style tag within the shadow root */
:host {
display: block;
padding: 1rem;
font-family: system-ui;
}
:host(.highlighted) {
background: #fffd;
}
The ::slotted() pseudo-element targets distributed nodes—elements that carry a slot attribute in the light DOM. This allows composition patterns where the host decides where projected content goes.
<app-header>
<span slot="title">My App</span>
</app-header>
// Inside the component
shadow.innerHTML = `
<style>
:host {
display: block;
}
slot[name="title"] {
font-weight: bold;
color: #222;
}
</style>
<h3><slot name="title">Default Title</slot></h3>
`;
Styles passed through <slot> respect the cascade of the light DOM, which can be surprising if you expect full encapsulation. If a slotted element has its own !important declarations or high-specificity rules, those will win over component-internal styles.
Custom Elements provide four lifecycle callbacks that fire at predictable moments. Only connectedCallback and disconnectedCallback are guaranteed to fire per instance; the others fire once during class definition or upgrade.
| Callback | When it fires |
|---|---|
constructor() | Once, during class definition. Use for initializing state, binding methods, or setting up observers. |
connectedCallback() | Every time the element is connected to a document. Use for DOM reads, event listeners, or starting timers. |
disconnectedCallback() | Every time the element is removed from a document. Use for cleanup: removing listeners, stopping intervals, or disconnecting observers. |
attributeChangedCallback(name, oldValue, newValue) | When an observed attribute changes, provided the element observes it via observedAttributes. |
customElements.define('counter-display', class extends HTMLElement {
constructor() {
super();
this.count = 0;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.incrementBtn = this.shadowRoot.getElementById('increment');
this.incrementBtn.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
this.incrementBtn.removeEventListener('click', this._handleClick);
}
attributeChangedCallback(name, old, value) {
if (name === 'value') this.count = Number(value);
this.render();
}
_handleClick() {
this.count++;
this.setAttribute('value', this.count);
}
render() {
this.shadowRoot.innerHTML = `
<p>Count: ${this.count}</p>
<button id="increment">+</button>
`;
}
});
Notice that connectedCallback runs each time the element enters a document. If you need one-time initialization, check this.__alreadyInitialized or use a flag on the class prototype. Failing to clean up in disconnectedCallback is a common source of memory leaks, especially when listeners reference this or hold timers.
Observed attributes must be listed in observedAttributes for attributeChangedCallback to fire. Without it, changing an attribute does nothing—no callback, no re-render.
static get observedAttributes() { return ['value', 'disabled']; }
Web components do not have a built-in event bus or state store. Communication relies on standard DOM mechanisms: custom events, properties, and the shadow DOM’s slot distribution.
Use this.dispatchEvent() to signal state changes or user actions. The event object should extend CustomEvent and can carry a detail payload.
customElements.define('search-box', class extends HTMLElement {
connectedCallback() {
this.shadowRoot.innerHTML = `
<input type="text" placeholder="Search…">
<button>Go</button>
`;
this.input = this.shadowRoot.querySelector('input');
this.button = this.shadowRoot.querySelector('button');
this.button.addEventListener('click', () => this._emitSearch());
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this._emitSearch();
});
}
_emitSearch() {
const query = this.input.value;
this.dispatchEvent(new CustomEvent('search', {
detail: { query },
bubbles: true,
composed: true
}));
}
});
bubbles: true allows the event to propagate up through the ancestor chain; composed: true allows it to cross the shadow boundary into the light DOM. Both are required if the event needs to be heard by code outside the component’s host tree. Without composed, the event stops at the shadow root and cannot be listened to from a parent element.
Parents attach listeners using addEventListener on the custom element tag. Because the event composes across the shadow boundary, the listener is placed on the element tag itself.
<search-box @search="${this._onSearch}"></search-box>
// In a parent class or script
<search-box id="search"></search-box>
<script>
const box = document.getElementById('search');
box.addEventListener('search', (e) => {
const query = e.detail.query;
console.log('Submitted:', query);
});
</script>
For simple data flow, use public properties. When a property changes, the browser does not re-render automatically—you must observe the change and update the DOM manually, or use a library pattern (e.g., a base class that re-renders on setter).
customElements.define('profile-card', class extends HTMLElement {
set name(value) {
this._name = value;
this.render();
}
get name() { return this._name; }
connectedCallback() {
this._name = this.getAttribute('name') || '';
this.render();
}
render() {
this.shadowRoot.innerHTML = `<p>Profile of ${this._name}</p>`;
}
});
<profile-card name="Taylor"></profile-card>
Setting a property programmatically or via HTML attribute both trigger the setter. The getter returns the current value. This pattern is lightweight but requires discipline: forget to call render() in the setter, and the UI will never update.
:host misuseA frequent error is assuming that :host styles are universally applied. If a component uses :host { display: block; } and the host document expects display: inline, the component may break layout expectations. Conversely, if a component relies on light-DOM selectors to style its internals, those rules will have no effect inside a shadow root. Always test components both with and without a shadow root to confirm the styling contract.
If attributeChangedCallback never fires, double-check two things: first, that observedAttributes is defined as a static getter returning the attribute names as an array of strings; second, that the attribute is actually being changed, not just present on initial markup. The callback only fires on change, not on definition. Also ensure the attribute name matches exactly—HTML is case-insensitive for reflection, but JavaScript string comparison is not.
Events dispatched with bubbles: true but without composed: true will not cross the shadow root boundary. If a parent listens for the event, it will never fire. This is the default in many tutorial examples, leading to confusion when the event is "missing." Always include composed: true when the event must be observable outside the component.
Placing DOM queries, event listeners, or timers in constructor() is incorrect—the element is not yet in a document, and this refers to a partially initialized instance. Always perform DOM work in connectedCallback and teardown in disconnectedCallback. Failing to remove listeners causes the callback to fire on a detached element, potentially throwing errors or updating stale state.
When cloning a template with cloneNode(true), the clone is a fresh DOM subtree. Any references held to elements inside the original template are not transferred. If you need to keep a reference to an element inside a cloned template, query the clone after insertion, or store the reference on the custom element instance before cloning.
Open the browser console and run customElements.get('app-header'). If the element is registered, it returns the constructor function; if not, it returns undefined. This is the fastest way to confirm whether a custom element has been defined, especially when multiple scripts are loading asynchronously.
In Chrome and Firefox, the developer tools tree includes a "Shadow DOM" section when an element has an open shadow root. Expanding it reveals the internal structure, styles, and distributed nodes. If the shadow root is closed, the section is hidden entirely—use element.shadowRoot in the console to verify openness.
Browsers emit console warnings for several common missteps:
customElements.define() ran.attachShadow() twice on the same element.webcomponents-custom-elements can register a fallback, but it adds runtime overhead.When a framework like React renders a web component, the initial markup may differ from what the component produces after connectedCallback runs. This can trigger hydration mismatch warnings if the framework is performing client-side reconciliation. The typical fix is to ensure the server-rendered markup matches the client-side output, or to defer the component’s interactive behavior until after the first paint.
Web components are appropriate when:
They are less appropriate when:
The decision ultimately hinges on reusability boundaries and team constraints. If a component must ship to an unknown consumer environment, web components provide a stable, framework-agnostic contract. If the component lives inside a single application where the framework is already a given, the productivity gains of framework-native components typically outweigh the encapsulation benefits of web components.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy