The Joy of Web Components

Posted on June 29, 2026

For a long time I reached for React or Vue whenever I needed to build a frontend. Anything beyond a static page seemed to require a framework. Setting up a bundler, configuring plugins, and adding dependencies just felt like the cost of doing business.

Then I actually tried using native Web Components. I expected a confusing API and a lot of missing features. Instead, I found a small set of browser APIs that handled most of what I needed. It turns out you do not need a framework for many of the things you build.

The browser already gives you the basic pieces for creating reusable UI elements. You define a class that extends HTMLElement, register it, and use it like any other HTML element.

Here is what a basic component looks like:

class GreetingCard extends HTMLElement {
  connectedCallback() {
    if (this.shadowRoot) {
      return;
    }

    const shadow = this.attachShadow({ mode: 'open' });

    const card = document.createElement('div');
    card.className = 'card';

    const heading = document.createElement('h2');
    heading.textContent = `Hello, ${this.getAttribute('name') || 'World'}!`;

    const style = document.createElement('style');
    style.textContent = `
      .card {
        padding: 1rem;
        border: 1px solid #ccc;
        border-radius: 4px;
      }
    `;

    card.append(heading);
    shadow.append(style, card);
  }
}

customElements.define('greeting-card', GreetingCard);

You can then use it in your HTML like any standard tag:

<greeting-card name="Alice"></greeting-card>

What struck me was how little ceremony there is. You do not need a build step to see this run in a browser. There is no virtual DOM to understand and no state management library to install. The lifecycle is straightforward. connectedCallback runs when the element is added to the document, and disconnectedCallback runs when it is removed.

If you want to react to attribute changes, you define observedAttributes and implement attributeChangedCallback.

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

  constructor() {
    super();
    this.handleClick = () => {
      const count = Number(this.getAttribute('count') || 0);
      this.setAttribute('count', count + 1);
    };
  }

  connectedCallback() {
    this.addEventListener('click', this.handleClick);
    this.render();
  }

  disconnectedCallback() {
    this.removeEventListener('click', this.handleClick);
  }

  attributeChangedCallback() {
    if (this.isConnected) {
      this.render();
    }
  }

  render() {
    const count = this.getAttribute('count') || 0;
    this.innerHTML = `<button type="button">Clicked ${count} times</button>`;
  }
}

customElements.define('counter-button', CounterButton);

Shadow DOM is another useful part of the platform. It keeps the component’s internal markup and styles separate from the rest of the page. It is not a magical wall around the component. Inherited styles and CSS custom properties can still cross the boundary, which is usually helpful rather than a problem.

I am not saying frameworks are useless. They are still a good fit for large applications with a lot of shared state, routing, server rendering, or a team that already knows the framework well. They also provide conventions that you otherwise have to choose for yourself.

But for a lot of websites, dashboards, and small web apps, native Web Components are enough. They work without a build step, use the browser’s existing APIs, and do not require shipping a whole application framework to the user.

I find myself enjoying frontend development a lot more now that I am writing standard HTML, CSS, and JavaScript again. There is something nice about opening a file in a browser and seeing the thing work immediately.