Vanilla Web Components: Building Reusable UI Elements Without Frameworks
Introduction
For the past decade, if you wanted to build a reusable UI component—say, a complex dropdown menu or a customized date picker—you almost certainly reached for React, Vue, or Angular. These frameworks solved a massive problem: they allowed developers to encapsulate HTML, CSS, and JavaScript into single, manageable pieces.
But the web platform has caught up. The browser natively supports encapsulating markup, styles, and logic using a suite of technologies collectively known as Web Components. The best part? Native web components are entirely framework-agnostic. You can build a component once and drop it into a React app, a Vue app, or a plain HTML page without changing a single line of code.
In this article, I am going to show you how to build a fully functional, styled, and encapsulated custom element using nothing but native HTML APIs and vanilla JavaScript.
Core Concepts: The Three Pillars
Web Components are not a single technology. They are a combination of three distinct browser APIs working together:
- Custom Elements: A set of JavaScript APIs that allow you to define custom HTML tags (like
<user-card>) and dictate their behavior. - Shadow DOM: A mechanism for attaching a hidden, separated Document Object Model (DOM) to an element. This ensures that the component’s internal CSS does not leak out, and external CSS does not bleed in.
- HTML Templates: The
<template>and<slot>elements, which allow you to write markup templates that are not rendered until you explicitly clone them via JavaScript.
Building Your First Custom Element
Let’s build something practical: a user profile card that accepts a name, an avatar URL, and a bio.
1. Defining the Template
First, we define our HTML structure and CSS using the <template> tag. The browser parses this HTML but does not render it to the screen.
.card {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
padding: 20px;
text-align: center;
max-width: 250px;
font-family: system-ui, sans-serif;
}
.avatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 15px;
}
h3 {
margin: 0 0 10px 0;
color: #333;
}
p {
color: #666;
font-size: 14px;
line-height: 1.4;
}
Default bio...
Notice the <slot> element inside the paragraph. Slots are placeholders. They allow consumers of your component to pass custom HTML into the component’s internal structure.
2. Creating the JavaScript Class
To bring the template to life, we create a JavaScript class that extends HTMLElement. In the constructor, we attach a Shadow DOM and clone the template into it.
class UserCard extends HTMLElement {
constructor() {
// Always call super() first in the constructor.
super();
// Attach a shadow root to the element.
// mode: 'open' means you can access it via element.shadowRoot from the outside.
this.attachShadow({ mode: 'open' });
// Grab the template and clone its content.
const template = document.getElementById('user-card-template');
const templateContent = template.content.cloneNode(true);
// Append the cloned content to the shadow root.
this.shadowRoot.appendChild(templateContent);
}
// The browser calls this method when the element is inserted into the DOM.
connectedCallback() {
// Read attributes passed from the HTML tag
const name = this.getAttribute('name');
const avatar = this.getAttribute('avatar');
// Update the internal shadow DOM elements
if (name) {
this.shadowRoot.querySelector('.name').textContent = name;
}
if (avatar) {
this.shadowRoot.querySelector('.avatar').src = avatar;
}
}
}
// Register the custom element with the browser.
// Custom element names MUST contain a hyphen.
customElements.define('user-card', UserCard);
3. Using the Component
Now that the component is registered, you can use it anywhere in your HTML document just like a standard HTML tag.
Frontend Developer focused on accessibility and web standards.
When the browser encounters the <user-card> tag, it instantiates the class, attaches the shadow DOM, applies the encapsulated CSS (which won’t affect the rest of your page), and seamlessly inserts the slotted bio text.
Handling State and Lifecycle Methods
Custom elements come with a set of native lifecycle callbacks that allow you to run code at specific points in the component’s life:
connectedCallback(): Invoked when the element is added to the DOM. Good for fetching data or setting up event listeners.disconnectedCallback(): Invoked when the element is removed. Good for cleaning up event listeners to prevent memory leaks.attributeChangedCallback(name, oldValue, newValue): Invoked when one of the element’s attributes is added, removed, or changed.
To use attributeChangedCallback, you must explicitly tell the browser which attributes to observe by defining a static observedAttributes getter.
class DynamicCounter extends HTMLElement {
static get observedAttributes() {
return ['count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'count') {
this.shadowRoot.querySelector('.display').textContent = newValue;
}
}
}
Common Mistakes / Pitfalls
1. Forgetting the Hyphen
According to the HTML specification, all custom element names must contain at least one hyphen (e.g., <data-table>, not <datatable>). This ensures your custom tags will never clash with future official HTML elements created by the W3C.
2. Overusing the Shadow DOM
While encapsulation is great, attaching a Shadow Root isn’t mandatory. If you are building an application-specific component that needs to inherit global CSS variables or typography styles heavily, you might not want a Shadow DOM. You can build Custom Elements that simply manipulate the light DOM (the normal document tree).
Best Practices
Use CSS Custom Properties (Variables) for theming. Because Shadow DOM blocks external CSS, you cannot easily theme a component from an external stylesheet by targeting its internal classes. However, CSS variables pierce the shadow boundary. If you define background: var(--card-bg, #fff); inside your component, developers can easily override it from the outside.
Reflect properties to attributes. If a user changes a property via JavaScript (e.g., el.disabled = true), your component should update the corresponding HTML attribute so the DOM state accurately reflects the JavaScript state. This is crucial for accessibility.
Conclusion
Web Components provide a robust, standardized way to build encapsulated UI elements without locking yourself into a specific framework ecosystem. While they require a bit more boilerplate than writing a React functional component, the benefits of true portability, longevity, and zero dependencies are massive. As the web platform continues to mature, mastering native Web Components is becoming an essential skill for modern frontend engineers.
FAQ
Do Web Components work with React?
Yes, but with caveats. React handles data passing and events slightly differently than standard DOM properties. However, React 19 significantly improves Web Component support, making them much easier to integrate natively.
Are they SEO friendly?
By default, content inside the Shadow DOM is not easily parsed by older crawlers. However, Googlebot executes JavaScript and indexes Shadow DOM content perfectly fine. If SEO is hyper-critical and you need zero-JS rendering, consider Declarative Shadow DOM (a newer spec that allows server-side rendering of Web Components).
Should I drop my framework and rewrite everything in Vanilla Web Components?
Probably not. Frameworks provide state management, routing, and ecosystem tools that Web Components do not. Web Components are best used for building design systems (buttons, modals, form inputs) that can be shared across multiple different framework projects in a large organization.
editor's pick
latest video
news via inbox
Nulla turp dis cursus. Integer liberos euismod pretium faucibua

