Web Components 101: Building Reusable UI Elements

As the web continues to evolve, developers face the challenge of creating scalable, maintainable, and reusable user interfaces. A key innovation addressing these challenges is Web Components—a set of web platform APIs that allow developers to create encapsulated, reusable UI elements using standard web technologies like HTML, CSS, and JavaScript. Unlike traditional methods that require using specific libraries or frameworks, Web Components are framework-agnostic, making them a powerful tool for building UI elements that can be used across different projects and platforms.

In this article, we will explore what Web Components are, how they work, and how you can use them to build reusable UI elements in your web applications. Whether you’re new to Web Components or just looking to deepen your understanding, this guide will provide a practical, hands-on approach to getting started.

What Are Web Components?

Web Components are a set of standard APIs that enable developers to create custom, reusable HTML elements that can be used across web applications. Web Components allow you to encapsulate your UI logic, styles, and markup into a single, self-contained component, preventing styles and scripts from leaking into other parts of your application.

Web Components are made up of three main technologies:

Custom Elements: This API allows you to define your own custom HTML tags. Custom elements work just like built-in HTML elements, but with the added benefit of being fully customizable.

Shadow DOM: The Shadow DOM provides encapsulation for a Web Component’s internal structure, meaning that styles and scripts inside the component won’t affect the rest of the document and vice versa.

HTML Templates: The template element is a mechanism for defining reusable chunks of HTML that can be instantiated multiple times. Templates can include markup, styles, and JavaScript logic, and they remain inactive until explicitly rendered into the DOM.

Together, these technologies provide the foundation for building powerful, reusable UI elements without the need for third-party libraries or frameworks.

Why Use Web Components?

With the rise of modern JavaScript frameworks like React, Vue, and Angular, you might wonder why Web Components are worth your attention. However, Web Components offer several distinct advantages:

1. Reusability Across Frameworks

Web Components are framework-agnostic, meaning they can be used in any JavaScript framework—or none at all. Whether you’re working in React, Vue, or plain HTML, you can create Web Components and reuse them across different projects. This makes them particularly valuable in environments where multiple frameworks are used or where different teams are working on various parts of a product.

2. Encapsulation and Isolation

With the Shadow DOM, Web Components offer complete encapsulation of their internal structure, styles, and behavior. This means you won’t have to worry about your component’s styles leaking out and affecting other parts of the page, or external styles unintentionally modifying your component. This level of isolation makes components more predictable and easier to maintain.

3. Native Browser Support

Unlike traditional UI libraries, Web Components are a native browser feature. This means there’s no need to rely on external libraries or tools to get started. Most modern browsers, including Chrome, Firefox, Edge, and Safari, now offer full support for Web Components, making them a future-proof solution for building reusable UI components.

4. Standardized Approach

Because Web Components are based on open web standards, they promote a consistent, standardized approach to building UI elements. This helps avoid the fragmentation that can occur with different UI libraries and ensures long-term compatibility and maintainability.

Getting Started with Web Components

Now that you have a basic understanding of what Web Components are and why they’re useful, let’s dive into how to create your first Web Component. In this section, we’ll walk through the process of creating a simple custom element using the three core technologies: Custom Elements, Shadow DOM, and HTML Templates.

Step 1: Creating a Custom Element

The first step in building a Web Component is defining a Custom Element. A custom element allows you to create your own HTML tag, which can be reused throughout your application. This is done using the customElements.define() API, which registers a new custom tag with the browser.

Here’s a basic example of defining a custom element:

class MyButton extends HTMLElement {
constructor() {
super(); // Call the constructor of the parent class (HTMLElement)
}

connectedCallback() {
this.innerHTML = `<button>Click me!</button>`;
}
}

// Define the custom element and associate it with the <my-button> tag
customElements.define('my-button', MyButton);

In this example, we define a custom element called my-button. The connectedCallback() method is part of the Custom Elements lifecycle, and it runs every time the element is added to the DOM. Inside this method, we set the inner HTML of the button.

You can now use this custom element in your HTML just like any other tag:

<my-button></my-button>

When this tag is rendered, the browser will insert a clickable button into the page.

Step 2: Adding Styles with Shadow DOM

To encapsulate the styles and structure of a Web Component, we can use the Shadow DOM. The Shadow DOM provides a separate, isolated tree of DOM elements that is invisible to the main document’s DOM. This means that styles applied inside the Shadow DOM will not affect other parts of the page, and vice versa.

Let’s modify our custom button to use the Shadow DOM and add some styles:

class MyButton extends HTMLElement {
constructor() {
super();
// Attach a shadow root to the element
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
background-color: blue;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
button:hover {
background-color: darkblue;
}
</style>
<button>Click me!</button>
`;
}
}

customElements.define('my-button', MyButton);

In this updated example, we use attachShadow() to attach a Shadow DOM to our custom element. This creates a separate scope for the component’s internal structure and styles. The mode: ‘open’ option allows external scripts to access the shadow DOM (for debugging purposes). Inside the Shadow DOM, we define our styles and markup in the innerHTML of the shadow root.

Now, the button’s styles are fully encapsulated and won’t interfere with or be affected by external styles in the main document.

Next, let’s introduce the HTML Template element to create more complex Web Components.

Step 3: Using HTML Templates

Next, let’s introduce the HTML Template element to create more complex Web Components. The template element allows you to define reusable markup that is not rendered until it is explicitly inserted into the DOM. This is particularly useful for components that involve more complex structure or require repeated content.

Here’s an example of how to use an HTML template in a Web Component:

<template id="my-button-template">
<style>
button {
background-color: blue;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
button:hover {
background-color: darkblue;
}
</style>
<button>Click me!</button>
</template>

<script>
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
// Get the template and clone its content
const template = document.getElementById('my-button-template');
const templateContent = template.content.cloneNode(true);

// Append the cloned template to the shadow root
this.shadowRoot.appendChild(templateContent);
}
}

customElements.define('my-button', MyButton);
</script>

In this example, we define an HTML template with an ID of my-button-template. The template contains both styles and markup for the button. Inside the Web Component’s connectedCallback() method, we clone the template’s content and insert it into the Shadow DOM. This approach keeps the component’s structure and style reusable, making it easy to maintain as the component grows in complexity.

Customizing Web Components with Attributes and Properties

One of the key benefits of Web Components is that they can be made dynamic by accepting attributes and properties. Just like standard HTML elements, custom elements can be configured using attributes, and their behavior can be modified based on those attributes.

Let’s extend our my-button component to accept a label attribute that will allow us to customize the button text.

class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

static get observedAttributes() {
return ['label'];
}

attributeChangedCallback(name, oldValue, newValue) {
if (name === 'label') {
this.shadowRoot.querySelector('button').textContent = newValue;
}
}

connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
background-color: blue;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
<button>${this.getAttribute('label') || 'Click me!'}</button>
`;
}
}

customElements.define('my-button', MyButton);

In this version, we define a label attribute that customizes the button’s text. The observedAttributes() static method tells the browser which attributes to watch for changes. When the label attribute is changed, the attributeChangedCallback() method is called, updating the button’s text content.

Now you can use the custom element like this:

<my-button label="Submit"></my-button>

This will render a button with the text “Submit.” If the attribute is later updated, the button’s text will change automatically.

Best Practices for Web Components

To ensure your Web Components are scalable, maintainable, and performant, it’s important to follow some best practices:

1. Keep Components Small and Focused

Each Web Component should have a clear, single responsibility. Avoid creating monolithic components that handle too much logic or functionality. By keeping components small and focused, you make them easier to maintain and reuse.

2. Encapsulate Styles and Structure

Use the Shadow DOM to encapsulate the component’s styles and structure, ensuring that it remains isolated from the rest of the application. This will prevent external styles from affecting the component and ensure that it behaves consistently across different environments.

3. Leverage Templates for Reusability

When building more complex components, use HTML templates to define reusable chunks of markup and styles. This will help keep your code clean and maintainable, especially as your components grow in complexity.

4. Use Properties and Attributes for Flexibility

Make your components flexible by allowing them to accept attributes and properties. This will make it easier to reuse them across different parts of your application while maintaining a consistent API.

5. Optimize Performance

Be mindful of performance, especially when creating components that render large amounts of data or involve heavy DOM manipulation. Use techniques like lazy loading and virtual DOM rendering to improve performance when necessary.

Advanced Web Components: Enhancing Your Reusable Elements

Now that you’re familiar with the basics of Web Components—including how to create custom elements, utilize the Shadow DOM, and work with HTML templates—let’s explore some more advanced techniques. These will help you fully unlock the potential of Web Components and create even more powerful, flexible, and scalable UI elements.

In this section, we will dive deeper into event handling, communication between components, slots for content projection, and lifecycle hooks. By the end, you will have a comprehensive understanding of how to leverage Web Components to build more complex and dynamic interfaces.

Handling Events in Web Components

Just like any other HTML element, Web Components can respond to user interactions and dispatch events. This makes them highly interactive and allows them to communicate with other components or external JavaScript code.

Let’s explore how to listen for events inside a Web Component and how to dispatch custom events that other parts of the application can listen to.

Listening for Events

You can easily handle events within a Web Component by attaching event listeners to elements inside the Shadow DOM. Here’s an example of how to add a click event listener to a button within a custom element:

class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
background-color: green;
color: white;
padding: 10px 20px;
border: none;
cursor: pointer;
}
</style>
<button>Click me</button>
`;

// Add an event listener for the button click
this.shadowRoot.querySelector('button').addEventListener('click', () => {
alert('Button clicked!');
});
}
}

customElements.define('my-button', MyButton);

In this example, a click event listener is added to the button inside the connectedCallback() method. When the button is clicked, an alert message is displayed.

Dispatching Custom Events

Sometimes, Web Components need to communicate with their parent components or other parts of the application. To do this, they can dispatch custom events. Custom events allow you to send information out of the Web Component, enabling other parts of your application to respond to it.

Here’s an example of dispatching a custom event when a button is clicked:

class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.innerHTML = `
<button>Click me</button>
`;

// Add an event listener for the button click
this.shadowRoot.querySelector('button').addEventListener('click', () => {
// Dispatch a custom event called 'button-clicked'
const event = new CustomEvent('button-clicked', {
bubbles: true,
composed: true,
detail: { message: 'Button was clicked!' }
});
this.dispatchEvent(event);
});
}
}

customElements.define('my-button', MyButton);

In this example, when the button is clicked, a custom event called button-clicked is dispatched. The bubbles: true option ensures that the event can bubble up through the DOM, and the composed: true option allows it to cross the Shadow DOM boundary.

You can now listen for this custom event from outside the Web Component:

<my-button></my-button>

<script>
document.querySelector('my-button').addEventListener('button-clicked', (event) => {
console.log(event.detail.message); // Logs "Button was clicked!"
});
</script>

This pattern is useful when you need Web Components to interact with their parent components or the global application logic, making your components more flexible and dynamic.

Communicating Between Web Components

In many cases, your Web Components will need to communicate with each other, especially when building more complex user interfaces. There are several strategies for allowing Web Components to share data or respond to events from other components.

One of the simplest ways to communicate between Web Components is by using properties and methods.

Using Properties and Methods

One of the simplest ways to communicate between Web Components is by using properties and methods. You can expose methods in one component that other components can call, or use properties to share data between them.

For example, let’s say you have a <counter-display> component that displays a number, and a <counter-button> component that increments the counter. You can set up communication between these components by exposing a method on the display component.

First, define the <counter-display> component:

class CounterDisplay extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.count = 0;
}

connectedCallback() {
this.render();
}

incrementCount() {
this.count += 1;
this.render();
}

render() {
this.shadowRoot.innerHTML = `<p>Count: ${this.count}</p>`;
}
}

customElements.define('counter-display', CounterDisplay);

Next, create the <counter-button> component that increments the counter:

class CounterButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.innerHTML = `<button>Increment</button>`;

this.shadowRoot.querySelector('button').addEventListener('click', () => {
const display = document.querySelector('counter-display');
if (display) {
display.incrementCount();
}
});
}
}

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

Now, you can use both components in your HTML:

<counter-display></counter-display>
<counter-button></counter-button>

In this example, the <counter-button> component finds the <counter-display> component and calls its incrementCount() method whenever the button is clicked, updating the displayed count. This pattern allows Web Components to interact without tight coupling, preserving modularity and maintainability.

Using Slots for Content Projection

One of the key features of Web Components is the ability to use slots for content projection. Slots allow developers to pass content into a Web Component from outside, making the component more flexible and reusable.

For example, imagine you have a custom <modal-dialog> component that displays a dialog box. You might want the ability to customize the content inside the modal from outside the component.

Here’s how you can use slots to achieve that:

class ModalDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
.modal {
background-color: white;
border: 1px solid #ccc;
padding: 20px;
width: 300px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
</style>
<div class="modal">
<slot></slot>
</div>
`;
}
}

customElements.define('modal-dialog', ModalDialog);

In this example, the <slot> element acts as a placeholder where external content can be projected into the component. You can now use the <modal-dialog> component and pass in content from outside:

<modal-dialog>
<h2>Confirmation</h2>
<p>Are you sure you want to continue?</p>
<button>Yes</button>
<button>No</button>
</modal-dialog>

When rendered, the content inside the <modal-dialog> tag is projected into the slot, allowing for dynamic, flexible content inside the modal.

Web Component Lifecycle Hooks

Like many JavaScript frameworks, Web Components have a set of lifecycle hooks that allow you to execute code at different points in the component’s lifecycle. These hooks help you manage when components are created, added to the DOM, updated, or removed.

The primary lifecycle hooks for Web Components are:

connectedCallback(): This method is called when the Web Component is added to the DOM. It’s a good place to set up the initial structure of your component or attach event listeners.

disconnectedCallback(): This method is called when the component is removed from the DOM. You can use it to clean up event listeners or other resources to avoid memory leaks.

attributeChangedCallback(name, oldValue, newValue): This method is triggered whenever one of the component’s observed attributes is changed. It allows you to react to changes in the component’s attributes and update the UI accordingly.

adoptedCallback(): This method is called when the component is moved to a new document, such as when using iframes.

Here’s an example using several lifecycle hooks:

class TimerComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.timer = null;
this.count = 0;
}

connectedCallback() {
this.shadowRoot.innerHTML = `<p>Time elapsed: <span>${this.count}</span> seconds</p>`;
this.startTimer();
}

disconnectedCallback() {
this.stopTimer();
}

startTimer() {
this.timer = setInterval(() => {
this.count += 1;
this.shadowRoot.querySelector('span').textContent = this.count;
}, 1000);
}

stopTimer() {
clearInterval(this.timer);
}
}

customElements.define('timer-component', TimerComponent);

In this example, the connectedCallback() method starts a timer when the component is added to the DOM, and the disconnectedCallback() method stops the timer when the component is removed. This ensures that the timer runs only when the component is active and avoids memory leaks when the component is removed.

Conclusion: The Future of Web Components

Web Components represent a powerful way to build reusable, encapsulated UI elements using standard web technologies. With native browser support, they offer a flexible, scalable solution for building components that can be reused across different projects and frameworks. Whether you’re working with React, Angular, Vue, or plain JavaScript, Web Components can enhance your development workflow by providing reusable elements that are easy to maintain and integrate.

At PixelFree Studio, we believe in the power of modular, reusable components to build scalable and maintainable web applications. Web Components provide a modern, standards-based solution to many of the challenges faced by frontend developers today. By embracing Web Components, you can simplify your codebase, reduce duplication, and build UIs that are future-proof and framework-independent.

Read Next: