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!
Elena Petrova
A senior front-end architect passionate about browser-native APIs and performant web applications.
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.
Technology | Purpose | Key Feature |
---|---|---|
Custom Elements | Lets you define your own HTML tags with custom logic and behavior. | Extends HTMLElement , giving you lifecycle callbacks like connectedCallback() . |
Shadow DOM | Provides encapsulation for your component’s markup and styles. | Creates a hidden, scoped DOM tree, preventing CSS from leaking in or out. |
HTML Templates | Allows 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:
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.