Reusable UI Building Blocks in Plain JavaScript
Web Components are a group of native browser APIs for building encapsulated UI building blocks that work without a framework runtime. Custom Elements, Shadow DOM and HTML Templates enable components that work equally well in React, Vue, Angular and plain HTML, a strong fit for design systems that span team and stack boundaries.
Table of Contents
- 1. What Web Components Really Are
- 2. Custom Elements: Lifecycle Callbacks and Registration
- 3. Shadow DOM: Encapsulating Styles and Markup
- 4. HTML Templates and Slots: Reusable Structures
- 5. Attributes and Properties: Controlling Data Flow
- 6. Custom Events: Communicating Outward
- 7. Styling Strategies: Host, Parts and Shared Stylesheets
- 8. Interoperability With React, Vue and Angular
- 9. Web Components Compared
- 10. Summary
- 11. FAQ
1. What Web Components Really Are
Web Components are not a single feature but a group of standardized browser APIs, Custom Elements for registering your own HTML tags, Shadow DOM for encapsulating markup and styles, and HTML Templates for declaratively reusing structure. The key difference from React or Vue components is that Web Components do not require a framework runtime. A registered custom element works in any browser that implements the specification, regardless of which frontend framework the surrounding application uses.
This framework independence makes Web Components especially interesting for design systems that must work across multiple teams with different technology stacks. A company where one department uses React and another uses Vue can ship a shared component library as Web Components instead of maintaining a separate implementation for each stack. The custom element <ms-button> behaves identically in both environments because the logic lives in the browser itself, not in the framework.
The following sections show the practical implementation of Web Components in detail, from registering a custom element, through encapsulation with Shadow DOM, to integration into existing framework applications. Every section contains runnable code that works directly in the browser without a build step, a core promise of Web Components compared to compiled framework components.
2. Custom Elements: Lifecycle Callbacks and Registration
The entry point into Web Components is the CustomElementRegistry, available through customElements.define(). A class extending HTMLElement is registered under a tag name that must contain a hyphen, to avoid collisions with future native HTML elements. From that point on, the browser recognizes the tag in markup and instantiates the class automatically as soon as the element appears in the DOM, with no additional JavaScript framework involved.
Four lifecycle callbacks govern the behavior of every custom element: connectedCallback runs once the element is inserted into the DOM, disconnectedCallback on removal, attributeChangedCallback on changes to observed attributes, and adoptedCallback when moved to another document. These callbacks replace the lifecycle hooks familiar from React or Vue, without needing an external library. That is precisely what makes Web Components the native alternative to framework-specific component models.
// Registering a Web Component with full lifecycle handling
class MsCounter extends HTMLElement {
static observedAttributes = ['start'];
#count = 0;
connectedCallback() {
this.#count = Number(this.getAttribute('start') ?? 0);
this.render();
this.addEventListener('click', this.#handleClick);
}
disconnectedCallback() {
this.removeEventListener('click', this.#handleClick);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'start' && oldValue !== newValue) {
this.#count = Number(newValue ?? 0);
this.render();
}
}
#handleClick = () => {
this.#count += 1;
this.render();
};
render() {
this.textContent = `Count: ${this.#count}`;
}
}
customElements.define('ms-counter', MsCounter);
3. Shadow DOM: Encapsulating Styles and Markup
Without encapsulation, global CSS rules would affect every component, a problem Web Components solve through Shadow DOM. Calling this.attachShadow({ mode: 'open' }) creates a separate DOM subtree for a custom element, one that is untouched by styles outside the component and does not leak its own styles outward. The open mode allows external access via element.shadowRoot, while closed denies that access, usually an unnecessary restriction for internal design systems.
Inside the Shadow DOM, a separate style context applies: a <style> block in the shadow root only affects elements inside that subtree. That is a structural advantage of Web Components over CSS modules or CSS-in-JS solutions in frameworks, because the browser itself guarantees the encapsulation rather than generated class names. For shared base styles across many instances, constructable stylesheets (CSSStyleSheet with adoptedStyleSheets) offer a performant alternative to duplicated inline <style> tags.
// Shadow DOM with a shared, constructable stylesheet
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { display: inline-block; border-radius: 8px; }
.badge { padding: 4px 10px; font-family: sans-serif; }
`);
class MsBadge extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet];
shadow.innerHTML = `<span class="badge"><slot></slot></span>`;
}
}
customElements.define('ms-badge', MsBadge);
4. HTML Templates and Slots: Reusable Structures
The <template> element is an inert markup block whose content is parsed by the browser but not rendered until it is cloned and inserted via JavaScript. Combined with custom elements, <template> forms the declarative foundation for the internal structure of Web Components, without needing to assemble markup as string templates. Cloning with template.content.cloneNode(true) is also more performant than repeated innerHTML parsing.
<slot> elements enable content projection: child elements a consumer writes into the light DOM appear at the slot's position inside the Shadow DOM, while remaining anchored in the original light DOM. Named slots (<slot name="footer">) allow multiple insertion points within a single component. This pattern of Web Components functionally corresponds to slots in Vue or the children prop in React, but as a native browser feature without virtual DOM diffing.
// Template + named slots for a reusable card component
const template = document.createElement('template');
template.innerHTML = `
<style>
.card { border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }
.footer { margin-top: 12px; font-size: 0.85em; color: #64748b; }
</style>
<div class="card">
<slot name="title"></slot>
<slot></slot>
<div class="footer"><slot name="footer"></slot></div>
</div>
`;
class MsCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('ms-card', MsCard);
5. Attributes and Properties: Controlling Data Flow
HTML attributes are always strings, while JavaScript properties can carry arbitrary values, a distinction every solid implementation of Web Components must handle explicitly. The common pattern: static observedAttributes defines which attributes trigger changes, attributeChangedCallback reacts to them, and getters/setters on the class mirror attributes into typed properties. That way element.setAttribute('start', '5') works just as well as element.start = 5, depending on whether the component is configured declaratively in markup or imperatively via JavaScript.
Complex data structures like arrays or objects cannot be meaningfully serialized as attributes, so many Web Components implementations rely exclusively on properties for such cases, while primitive configuration values go through attributes. This separation, attributes for simple, serializable values and properties for complex data, is one of the most important conventions when working with Web Components, and it is consistently applied by libraries like Lit as well.
6. Custom Events: Communicating Outward
Because Web Components have no central state container, outward communication happens through the native CustomEvent interface. A custom element dispatches an event with this.dispatchEvent(new CustomEvent('ms-change', { detail: value, bubbles: true, composed: true })), and the surrounding code, whether plain HTML, React or Vue, registers a regular addEventListener. The composed: true flag is essential because it lets the event cross the Shadow DOM boundary and become visible in the light DOM.
This event-based communication makes Web Components good citizens in any environment: React developers wire up events via refs and addEventListener, because JSX does not treat custom events like native DOM events by default. Vue, on the other hand, already supports custom events from Web Components natively through the regular v-on syntax. Anyone planning a component library as Web Components should document events consistently, since they are the only public outbound interface besides properties.
// Dispatching a custom event that crosses the shadow boundary
class MsToggle extends HTMLElement {
connectedCallback() {
this.addEventListener('click', () => {
const next = this.getAttribute('checked') !== 'true';
this.setAttribute('checked', String(next));
this.dispatchEvent(new CustomEvent('ms-toggle', {
detail: { checked: next },
bubbles: true,
composed: true, // crosses shadow DOM boundary
}));
});
}
}
customElements.define('ms-toggle', MsToggle);
// Consumer code, framework agnostic
document.querySelector('ms-toggle')
.addEventListener('ms-toggle', (event) => {
console.log('Toggle state:', event.detail.checked);
});
7. Styling Strategies: Host, Parts and Shared Stylesheets
The :host pseudo-selector styles the custom element itself from within the Shadow DOM, while :host(.active) reacts to classes set from outside. For cases where consumers of a component should be able to style specific internal elements without fully giving up encapsulation, the part attribute combined with the CSS selector ::part(name) offers a controlled escape hatch from Shadow DOM. This pattern lets Web Components grant targeted customizability without fully exposing internal structure.
For design tokens that should stay consistent across many instances, CSS custom properties (--ms-primary-color) are the preferred approach, because unlike regular styles they pierce the Shadow DOM boundary and can be set from the surrounding document. Constructable stylesheets via adoptedStyleSheets additionally reduce memory usage when a thousand instances of the same component exist on one page, since the stylesheet is parsed once instead of duplicated a thousand times.
// :host, ::part and CSS custom properties for controlled styling
class MsPanel extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { --ms-primary-color: #0f172a; display: block; }
.header { background: var(--ms-primary-color); color: white; padding: 12px; }
</style>
<div class="header" part="header"><slot name="header"></slot></div>
<div part="body"><slot></slot></div>
`;
}
}
customElements.define('ms-panel', MsPanel);
8. Interoperability With React, Vue and Angular
React long struggled to handle properties and custom events from Web Components cleanly, because JSX sets attributes rather than properties by default, and custom events are not automatically wired up like synthetic events. The usual workaround is a ref, through which properties are set directly and addEventListener is registered manually. Newer React versions improve native support, yet for complex Web Components a thin adapter layer usually remains the more reliable solution.
Vue supports Web Components much more directly: with compilerOptions.isCustomElement, the compiler knows which tags are not Vue components, and properties and events from custom elements are bound through the regular template syntax. Angular requires the CUSTOM_ELEMENTS_SCHEMA in the relevant module, after which Web Components work like native HTML inside Angular templates. On the authoring side, the Lit library significantly eases building robust Web Components without deviating from the underlying browser API.
9. Web Components Compared
The choice between Web Components and framework components is rarely a matter of taste alone but depends on the context of use: a shared component library across multiple stacks benefits strongly from Web Components, while a single React application without interop needs is usually better served by native React components.
| Criterion | Web Components | Framework Components | Practical Note |
|---|---|---|---|
| Runtime dependency | None, native browser | Framework runtime required | Important for shared design systems |
| Style encapsulation | Shadow DOM, guaranteed | CSS modules or CSS-in-JS | Shadow DOM prevents leaks structurally |
| Server-side rendering | Limited, needs Declarative Shadow DOM | Mature (Next.js, Nuxt) | SSR-heavy pages are often faster with a framework |
| Cross-framework use | Native, identical everywhere | Only within its own ecosystem | Decisive for multi-team design systems |
| Development ergonomics | Improved by Lit | Mature tooling and dev tools | Frameworks are usually more productive day to day |
In practice, the strongest advantages of Web Components emerge where reusability across teams and technology boundaries matters more than maximum development speed within a single framework. Design systems of large organizations increasingly rely on Web Components as a shared base, complemented by framework-specific wrappers for more convenient integration.
Mironsoft
Frontend architecture, design systems and cross-framework components
A design system every frontend team can use?
We build Web Components that work as a shared base across React, Vue and plain HTML, with Shadow DOM, clear attribute-property conventions and documented custom events.
Component audit
Analysis of existing UI building blocks for reusability across stacks
Web Components library
Building a design system with Custom Elements, Shadow DOM and Lit
Framework integration
Adapters for React, Vue and Angular for seamless use of existing components
10. Summary
Web Components are the browser's native answer to reusable, encapsulated UI building blocks, without needing to ship a framework runtime alongside them. Custom Elements register their own tags with clear lifecycle callbacks, Shadow DOM guarantees style encapsulation at the browser level, and HTML Templates with Slots provide a declarative structure for reusable content. Custom Events handle outward communication where framework components would normally rely on prop callbacks or store bindings.
The biggest lever of Web Components lies in cross-framework reusability: a component built once works in React, Vue, Angular and plain HTML without porting. For organizations with multiple frontend teams and differing technology decisions, that is a structural advantage no framework-specific component model can offer. Libraries like Lit reduce the boilerplate involved without deviating from the underlying Web Components specification.
Web Components Without a Framework — Key Takeaways
Custom Elements
customElements.define() registers custom tags with lifecycle callbacks like connectedCallback and attributeChangedCallback.
Shadow DOM
attachShadow({ mode: 'open' }) structurally encapsulates styles and markup, no global CSS leaks.
Templates & Slots
<template> and <slot> provide declarative structure and content projection without a virtual DOM.
Interoperability
Works in React (with an adapter), Vue (natively) and Angular (CUSTOM_ELEMENTS_SCHEMA) alike.