Web Components let you create fully encapsulated, reusable HTML elements with Custom Elements, Shadow DOM, and HTML Templates, no framework needed. They work everywhere, play nice with every framework, and they're built right into the browser. Time to learn the platform!

Ever felt like you need React, Vue, or Angular just to build a reusable button? What if I told you the browser already has a built-in component model that does exactly that, and it works everywhere?

Welcome to Web Components. They've been around for a while, but in 2024/2025 they've matured into a serious option for building UI elements that are truly portable. Let's dive in and see what all the fuss is about.

What Are Web Components, Anyway? πŸ€”

Web Components is an umbrella term for a set of browser-native APIs that let you define your own HTML elements. Think <my-button> or <user-card>, elements that encapsulate their own markup, styles, and behavior.

The three pillars are:

  • Custom Elements, Define new HTML tags with their own logic
  • Shadow DOM, Encapsulate styles and markup so nothing leaks in or out
  • HTML Templates, Declare inert HTML fragments with <template> and <slot>

Together, they give you a framework-free way to build self-contained UI components. Let's look at each one.

Web Components - Web APIs | MDN
Web Components is a suite of different technologies allowing you to create reusable custom elements with their functionality encapsulated away from the rest of your code.

Custom Elements: Your Own HTML Tags 🏷️

The foundation of Web Components is the Custom Elements API. You extend HTMLElement, define your behavior, and register it with the browser. That's it.

Here's the simplest possible custom element:

class HelloWorld extends HTMLElement {
  connectedCallback() {
    const p = document.createElement('p');
    p.textContent = 'Hello from a Web Component!';
    this.appendChild(p);
  }
}

customElements.define('hello-world', HelloWorld);

Now you can use <hello-world></hello-world> anywhere in your HTML and it just works. No build step, no bundler, no npm install. Just the browser doing its thing.

One important rule: custom element names must contain a hyphen. That's how the browser distinguishes them from built-in elements. So hello-world is valid, but helloworld is not.

Shadow DOM: Style Isolation for Real πŸ›‘οΈ

You know that feeling when your component's CSS accidentally breaks something else on the page? Or when some global style bleeds into your carefully crafted widget? Shadow DOM fixes that.

Shadow DOM creates an isolated DOM tree attached to your element. Styles inside the shadow root don't leak out, and external styles don't leak in. It's like an iframe, but without all the headaches.

class StyledCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const wrapper = document.createElement('div');
    wrapper.setAttribute('class', 'card');

    const style = document.createElement('style');
    style.textContent = `
      .card {
        border: 2px solid #6c5ce7;
        border-radius: 12px;
        padding: 1.5rem;
        font-family: system-ui, sans-serif;
        background: linear-gradient(135deg, #dfe6e9, #b2bec3);
      }
      .card h3 {
        margin: 0 0 0.5rem 0;
        color: #6c5ce7;
      }
    `;

    const title = document.createElement('h3');
    title.textContent = this.getAttribute('card-title') || 'Untitled';

    const body = document.createElement('p');
    body.textContent = this.getAttribute('card-body') || '';

    wrapper.appendChild(title);
    wrapper.appendChild(body);
    shadow.appendChild(style);
    shadow.appendChild(wrapper);
  }
}

customElements.define('styled-card', StyledCard);

Even if you have a global .card class with completely different styles, it won't touch this component. That's the power of encapsulation.

Lifecycle Callbacks: Knowing When Things Happen ⏱️

Custom elements come with a set of lifecycle callbacks that let you hook into key moments. If you've used React or Angular, these will feel familiar:

Callback When it fires Use case
constructor() Element is created Initialize state, attach shadow DOM
connectedCallback() Element is added to the DOM Render content, set up listeners
disconnectedCallback() Element is removed from the DOM Clean up listeners, timers
attributeChangedCallback() An observed attribute changes React to attribute updates
adoptedCallback() Element is moved to a new document Rarely used (iframe scenarios)

The most important ones are connectedCallback (your "mount") and disconnectedCallback (your "unmount"). Here's a practical example with attribute observation:

class CounterElement extends HTMLElement {
  static get observedAttributes() {
    return ['count'];
  }

  constructor() {
    super();
    this._shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this._render();
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'count') {
      this._render();
    }
  }

  _render() {
    // Clear existing content
    while (this._shadow.firstChild) {
      this._shadow.removeChild(this._shadow.firstChild);
    }

    const count = parseInt(this.getAttribute('count') || '0', 10);

    const style = document.createElement('style');
    style.textContent = `
      .counter { font-family: system-ui; text-align: center; padding: 1rem; }
      button { font-size: 1.2rem; padding: 0.5rem 1rem; cursor: pointer; margin: 0 0.25rem; }
      span { font-size: 2rem; font-weight: bold; color: #6c5ce7; }
    `;

    const div = document.createElement('div');
    div.setAttribute('class', 'counter');

    const minusBtn = document.createElement('button');
    minusBtn.textContent = '-';
    minusBtn.addEventListener('click', () => {
      this.setAttribute('count', String(count - 1));
    });

    const span = document.createElement('span');
    span.textContent = String(count);

    const plusBtn = document.createElement('button');
    plusBtn.textContent = '+';
    plusBtn.addEventListener('click', () => {
      this.setAttribute('count', String(count + 1));
    });

    div.appendChild(minusBtn);
    div.appendChild(span);
    div.appendChild(plusBtn);

    this._shadow.appendChild(style);
    this._shadow.appendChild(div);
  }
}

customElements.define('my-counter', CounterElement);

Every time you change the count attribute, whether from JS or from the DevTools, the component re-renders automatically. Reactive UI without a framework!

Slots: Composable Content Projection 🎰

Slots let consumers of your component inject their own content into predefined "holes." If you know Angular's ng-content or React's children, same idea.

class InfoBox extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const style = document.createElement('style');
    style.textContent = `
      .box {
        border-left: 4px solid #0984e3;
        padding: 1rem 1.5rem;
        background: #dfe6e9;
        border-radius: 0 8px 8px 0;
        margin: 1rem 0;
      }
      .box-title {
        font-weight: bold;
        color: #0984e3;
        margin-bottom: 0.5rem;
      }
    `;

    const box = document.createElement('div');
    box.setAttribute('class', 'box');

    const titleDiv = document.createElement('div');
    titleDiv.setAttribute('class', 'box-title');
    const titleSlot = document.createElement('slot');
    titleSlot.setAttribute('name', 'title');
    titleSlot.textContent = 'Info';
    titleDiv.appendChild(titleSlot);

    const contentSlot = document.createElement('slot');

    box.appendChild(titleDiv);
    box.appendChild(contentSlot);
    shadow.appendChild(style);
    shadow.appendChild(box);
  }
}

customElements.define('info-box', InfoBox);

And in your HTML:

<info-box>
  <span slot="title">Did you know?</span>
  <p>Web Components work in every modern browser!</p>
</info-box>

The named slot receives the <span slot="title">, and the default slot gets everything else. Clean, declarative, and zero magic.

HTML Templates: Stamp Out Fragments πŸ“‹

The <template> element lets you declare HTML that isn't rendered until you clone it. Combined with Shadow DOM, it's a great way to define your component's structure:

<template id="user-card-template">
  <style>
    .card { display: flex; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 8px; }
    .avatar { width: 48px; height: 48px; border-radius: 50%; }
    .name { font-weight: bold; }
  </style>
  <div class="card">
    <img class="avatar" />
    <div>
      <div class="name"><slot name="name">Anonymous</slot></div>
      <div><slot name="role">Member</slot></div>
    </div>
  </div>
</template>
class UserCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    const template = document.getElementById('user-card-template');
    shadow.appendChild(template.content.cloneNode(true));

    const avatar = shadow.querySelector('.avatar');
    avatar.src = this.getAttribute('avatar') || '';
    avatar.alt = this.getAttribute('name') || 'User avatar';
  }
}

customElements.define('user-card', UserCard);

Templates are parsed but not rendered, so they have zero performance cost until you actually use them.

Framework Interop: Playing Nice With Others 🀝

One of the biggest selling points of Web Components is that they work everywhere. React, Vue, Angular, Svelte, plain HTML, doesn't matter. It's just a DOM element.

In React:

function App() {
  return (
    <div>
      <styled-card card-title="Hello React" card-body="Web Components work here too!">
      </styled-card>
    </div>
  );
}

In Vue:

<template>
  <styled-card :card-title="title" card-body="Vue plays nice!">
  </styled-card>
</template>

In Angular:

<styled-card [attr.card-title]="title" card-body="Angular works too!">
</styled-card>

The key thing: you don't need a wrapper library. You don't need a special binding. It's a DOM element. Every framework knows how to render DOM elements.

There's one small gotcha with React: passing complex data (objects/arrays) via attributes requires serialization. For those cases, you can use element refs and set properties directly.

Angular Signals: Reactive State Management Without RxJS πŸš€
Angular Signals offer a simpler alternative to RxJS for reactive state management in Angular applications.

When Should You Use Web Components? 🎯

Web Components shine in specific scenarios:

  • Design systems, Build once, use in React, Vue, Angular, or plain HTML
  • Micro-frontends, Encapsulate entire features as custom elements
  • Third-party widgets, Embed components in other people's pages without style conflicts
  • Progressive enhancement, Add interactivity to server-rendered HTML
  • Long-lived projects, Frameworks come and go, the platform stays

They're probably not the best choice if you're building a full SPA from scratch (you'll miss the ecosystem benefits of a framework) or if you need server-side rendering out of the box.

htmx: Interactive Websites Without JavaScript Frameworks πŸ”„
htmx offers an alternative approach to interactive websites – without heavy JavaScript frameworks.

Libraries That Make It Easier πŸ› οΈ

You can absolutely write Web Components with vanilla JS, but a few libraries make the developer experience much better:

  • Lit, Google's lightweight library. Adds reactive properties, declarative templates, and weighs only ~5KB. The most popular choice.
  • Stencil, A compiler that generates standards-compliant Web Components with TypeScript and JSX.
  • Preact, Can render into Shadow DOM with minimal setup.

If you're just getting started, I'd recommend trying Lit. It removes a lot of boilerplate while keeping you close to the platform.

Lit β€” Simple. Fast. Web Components.
Lit is a simple library for building fast, lightweight web components that work in any framework.

Browser Support 🌐

All modern browsers support Web Components natively: Chrome, Firefox, Safari, and Edge. IE11 is the only holdout, and honestly, if you're still supporting IE11 in 2025... we need to talk.

web-components-polyfill (GitHub)
Polyfill for older browsers that don't support Web Components natively.

Wrapping Up 🎁

Web Components give you a powerful, standards-based way to build reusable UI elements. No framework lock-in, no build step required, and they work everywhere. The APIs have matured significantly, and with libraries like Lit making the DX smooth, there's never been a better time to give them a shot.

Start small, build a custom button, a tooltip, or a card component. Once you see how clean the encapsulation is, you'll start reaching for Web Components more often. Happy coding!