Web Development

The 2025 Guide: 7 Steps to Master Vanilla Web Components

Ready to ditch framework churn? Our 2025 guide breaks down mastering vanilla Web Components into 7 easy steps. Build reusable, future-proof UI today!

E

Elena Petrova

A senior front-end architect passionate about browser-native APIs and performant web applications.

7 min read18 views

Tired of JavaScript Framework Fatigue? It’s Time to Revisit Web Components.

Let’s be honest. The world of front-end development moves at a dizzying pace. One year, it’s all about React hooks; the next, Svelte 5 is changing the game. This constant churn, often called “framework fatigue,” can be exhausting. While these tools are incredibly powerful, what if there was a more stable, future-proof way to build reusable UI elements? A way that’s baked right into the browser itself?

Enter Vanilla Web Components. They’re not a new, flashy framework. They’re a set of web platform APIs that have been maturing for years, allowing you to create custom, reusable, and encapsulated HTML tags. Think of building your own <video-player> or <user-profile-card> that works in any project, with or without a framework.

In 2025, Web Components are more capable and better supported than ever. They represent a shift towards leveraging the native power of the browser. This guide will demystify the process and walk you through seven clear steps, taking you from a complete beginner to someone who can confidently build and deploy their own custom elements.

Step 1: Understand the Core Concepts

Before we write any code, let’s grasp the three main technologies that form the foundation of Web Components. They work together to create powerful, isolated components.

TechnologyPurposeKey Feature
Custom ElementsLets you define your own HTML tags with custom logic and behavior.Extends HTMLElement, giving you lifecycle callbacks like connectedCallback().
Shadow DOMProvides encapsulation for your component’s markup and styles.Creates a hidden, scoped DOM tree, preventing CSS from leaking in or out.
HTML TemplatesAllows you to define inert chunks of markup that can be cloned and used later.The content of a <template> tag is not rendered until you activate it with JavaScript.

Think of it this way: Custom Elements are the body, Shadow DOM is the protective skin, and HTML Templates are the DNA blueprint for creating new instances.

Step 2: Define Your First Custom Element

Let’s get our hands dirty. The heart of a web component is a JavaScript class that extends HTMLElement. This gives your element all the properties and methods of a standard HTML element.

Here’s the classic “Hello, World!” of web components:

// Define the class for our new element
class GreetingMessage extends HTMLElement {
  constructor() {
    // Always call super() first in the constructor
    super();

    // Initial setup (don't touch the DOM here yet)
    console.log("GreetingMessage constructed!");
  }

  connectedCallback() {
    // Called when the element is inserted into the DOM
    this.innerHTML = `<p>Hello, Web Components!</p>`;
  }
}

// Tell the browser about our new element
customElements.define('greeting-message', GreetingMessage);

To use it, you just place <greeting-message></greeting-message> in your HTML file. When the browser sees this tag, it will instantiate your GreetingMessage class and call connectedCallback(), rendering your message. Easy, right? But there’s a problem: any global CSS like p { color: red; } would affect our component. Let's fix that.

Step 3: Encapsulate with the Shadow DOM

The Shadow DOM is the magic that makes your components truly self-contained. It creates a separate, “shadow” DOM tree for your element. Styles and scripts inside this shadow tree don’t affect the main document, and vice-versa.

Let’s refactor our component to use it:

Advertisement
class GreetingMessage extends HTMLElement {
  constructor() {
    super();
    // Attach a shadow root to the element.
    // 'open' mode means you can access the shadow DOM from JavaScript.
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // Now, we append content to the shadowRoot instead of using innerHTML
    this.shadowRoot.innerHTML = `
      <style>
        p {
          color: green;
          font-family: sans-serif;
        }
      </style>
      <p>Hello, Encapsulated World!</p>
    `;
  }
}

customElements.define('greeting-message-shadow', GreetingMessage);

Now, the p style is scoped only to this component. A global p { color: red; } style on your page will not affect “Hello, Encapsulated World!”. This is true encapsulation.

Step 4: Make it Reusable with Templates and Slots

Hardcoding HTML inside a JavaScript string isn’t ideal. For more complex components, we use the <template> and <slot> elements. A <template> is an inert piece of DOM, parsed but not rendered, making it highly efficient to clone.

A <slot> is a placeholder inside your component that you can fill with your own markup from the outside. This is called “content projection.”

Let's create a more flexible profile card:

// In your HTML, define the template
<template id="profile-card-template">
  <style> .card { border: 1px solid #ccc; padding: 1rem; } </style>
  <div class="card">
    <h3><slot name="username">Default User</slot></h3>
    <div class="details">
      <slot>Default bio content goes here.</slot>
    </div>
  </div>
</template>

// In your JavaScript
class ProfileCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const template = document.getElementById('profile-card-template');
    const content = template.content.cloneNode(true);
    this.shadowRoot.appendChild(content);
  }
}
customElements.define('profile-card', ProfileCard);

// How to use it in HTML:
<profile-card>
  <span slot="username">Elena Petrova</span>
  <p>Loves building things for the web.</p>
</profile-card>

Notice how the <span> with slot="username" is projected into the <slot name="username"> placeholder. The <p> tag without a name goes into the default (unnamed) <slot>.

Step 5: Handle Attributes and Properties

Components need to accept data. The two primary ways are via HTML attributes (e.g., <my-el attribute="value">) and JavaScript properties (e.g., myEl.property = 'value').

A best practice is to keep them in sync. Web components provide a lifecycle callback to react to attribute changes.

Let’s create a user avatar component that takes a src attribute:

class UserAvatar extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<img alt="User Avatar">`;
    this._imgElement = this.shadowRoot.querySelector('img');
  }

  // 1. Specify which attributes to observe for changes
  static get observedAttributes() {
    return ['src'];
  }

  // 2. React to changes in those attributes
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'src' && oldValue !== newValue) {
      this._imgElement.src = newValue;
    }
  }

  // 3. (Optional but good practice) Create a JS property getter/setter
  get src() {
    return this.getAttribute('src');
  }

  set src(value) {
    this.setAttribute('src', value);
  }
}

customElements.define('user-avatar', UserAvatar);

// Usage: <user-avatar src="/path/to/image.jpg"></user-avatar>

Now, whenever the `src` attribute changes on the element, `attributeChangedCallback` fires and updates the internal `` tag. The getter/setter pair allows you to interact with it programmatically (e.g., `document.querySelector('user-avatar').src = 'new.jpg';`).

Step 6: Manage State and Events

Components aren’t just for display; they need to be interactive. This involves managing internal state and communicating changes to the outside world. The standard way to communicate *out* of a component is by dispatching custom events.

Let’s build a simple counter button:

class CounterButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    // Internal state
    this._count = 0;

    this.shadowRoot.innerHTML = `
      <button>Times Clicked: 0</button>
    `;

    this._button = this.shadowRoot.querySelector('button');
    this._button.addEventListener('click', this._increment.bind(this));
  }

  _increment() {
    this._count++;
    this._render();

    // Dispatch a custom event to notify the outside world
    this.dispatchEvent(new CustomEvent('countChanged', {
      detail: { count: this._count } // Pass data with the event
    }));
  }

  _render() {
    this._button.textContent = `Times Clicked: ${this._count}`;
  }
}

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

// In your main script, you can listen for this event
const myCounter = document.querySelector('counter-button');
myCounter.addEventListener('countChanged', (event) => {
  console.log(`The count is now: ${event.detail.count}`);
});

This pattern is powerful. The component manages its own state and logic, and provides a clean event-based API for other parts of your application to hook into, without needing to know about its internal implementation.

Step 7: Style Your Component Like a Pro

Styling is often a point of confusion. Because of the Shadow DOM, external styles don’t get in, but how do you let users customize your component? You have a few professional-grade tools at your disposal.

The :host Selector

Inside your Shadow DOM's <style> tag, :host allows you to style the component element itself (the “host” element).

:host { 
  display: inline-block; /* By default, custom elements are display: inline */
  border: 1px solid gray;
}

:host([disabled]) { /* Style the host when it has a 'disabled' attribute */
  opacity: 0.5;
  pointer-events: none;
}

CSS Custom Properties (Variables)

This is the primary way to create a “theming API” for your component. Define default values for custom properties inside your component, which users can then override from the outside.

/* Inside the component's <style> tag */
:host {
  --button-background: dodgerblue;
  --button-color: white;
}
button {
  background-color: var(--button-background);
  color: var(--button-color);
}

/* In your global stylesheet, a user can override them */
counter-button {
  --button-background: darkred;
}

The ::part() Pseudo-element

Sometimes, you need to give users more granular control. By adding a part attribute to an element inside your Shadow DOM, you can make it targetable from the outside using the ::part() pseudo-element.

// Inside the component's template
<button part="counter-btn">Click Me</button>

// In your global stylesheet
counter-button::part(counter-btn) {
  border-radius: 8px;
  box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
}

Using these three techniques—:host, custom properties, and ::part()—gives you a robust and flexible styling system that respects encapsulation while allowing for necessary customization.

Conclusion: You're Ready to Build!

And there you have it! We've journeyed through the seven essential steps to mastering vanilla Web Components. From understanding the core concepts and defining your first element, to managing state, events, and advanced styling, you now have the foundational knowledge to build your own robust, reusable, and framework-agnostic UI components.

The beauty of Web Components is their longevity. They aren’t a fleeting trend; they are a web standard. The components you build today will work in browsers for years to come. So go ahead, start small with a custom button or a profile card, and see how liberating it can be to build directly on the platform.

Tags

You May Also Like