Web Components Without a Framework: Reusable UI Building Blocks in Plain JavaScript
AI generated
JS
() =>
JavaScript · Web Components · Custom Elements · Framework agnostic
Web Components Without a Framework
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.

18 min read Custom Elements · Shadow DOM · Templates · Slots All modern browsers

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.

11. FAQ: Web Components Without a Framework

1What exactly are Web Components?
Native browser APIs, Custom Elements, Shadow DOM and Templates, for encapsulated, reusable UI building blocks without a framework runtime.
2Do I need Lit for Web Components?
No, plain JavaScript is enough. Lit reduces boilerplate but is optional.
3Do Web Components work in React?
Yes, with a ref-based adapter for properties and addEventListener for custom events.
4Shadow DOM vs. CSS modules?
Shadow DOM guarantees encapsulation at the browser level, CSS modules only through unique class names at build time.
5When to use mode: closed?
Rarely, it makes debugging harder. mode: open is more practical for most design systems.
6How do Web Components communicate outward?
Through CustomEvent with bubbles and composed, so the event crosses the shadow boundary.
7Attributes vs. properties?
Attributes are strings in markup, properties carry arbitrary values. Getters/setters mirror between the two.
8Does Vue support Web Components natively?
Yes, via compilerOptions.isCustomElement, without an extra adapter.
9Advantage of adoptedStyleSheets?
A stylesheet is parsed once and shared across many instances instead of being duplicated.
10Are Web Components suitable for SSR?
Limited via Declarative Shadow DOM, the ecosystem is less mature than Next.js or Nuxt.