Web Components: Building Reusable UI Elements

Web Components are a set of web platform APIs that allow developers to create reusable, encapsulated custom HTML elements. They use technologies like Custom Elements, Shadow DOM, and HTML Templates to build modular UI components.

Web Components: Building Reusable UI Elements

Web Components are a set of web platform APIs that allow developers to create reusable, encapsulated custom HTML elements. They enable you to build your own HTML tags that work seamlessly with any JavaScript framework or no framework at all. Unlike framework-specific components like React components or Vue components, Web Components are built on native browser standards, making them truly portable and framework-agnostic.

The promise of Web Components is simple yet powerful: write a component once, use it anywhere. Whether you are building a vanilla JavaScript application, a React app, an Angular project, or a static HTML site, Web Components work consistently across all environments. To understand Web Components properly, it is helpful to be familiar with concepts like JavaScript ES6, DOM manipulation, HTML5 basics, and CSS encapsulation.

What Are Web Components

Web Components are a suite of browser standards that enable developers to create custom, reusable HTML elements with encapsulated functionality and styling. They consist of three main technologies that work together to provide a complete component model for the web.

  • Custom Elements: JavaScript APIs that allow you to define new HTML tags and extend existing ones.
  • Shadow DOM: Encapsulated DOM tree that prevents style and markup conflicts with the main document.
  • HTML Templates: The template and slot elements that define reusable markup structures.
  • ES Modules: JavaScript modules for importing and exporting component definitions.
Web Components architecture:
┌─────────────────────────────────────────────────────────┐
│                    Web Component                         │
├─────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐     │
│  │  Custom     │  │   Shadow    │  │   HTML      │     │
│  │  Elements   │  │    DOM      │  │  Templates  │     │
│  └─────────────┘  └─────────────┘  └─────────────┘     │
│                                                          │
│  ┌─────────────────────────────────────────────────┐   │
│  │              Key Capabilities                    │   │
│  │  • Framework-agnostic (works anywhere)          │   │
│  │  • Style encapsulation (no CSS conflicts)       │   │
│  │  • DOM encapsulation (no selector conflicts)    │   │
│  │  • Lifecycle callbacks (connected, disconnected)│   │
│  │  • Native browser support (no runtime needed)   │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

Why Web Components Matter

Web Components solve several long-standing problems in web development, from style encapsulation to cross-framework compatibility. Their importance continues to grow as the web platform evolves.

  • Framework Agnostic: Web Components work with React, Vue, Angular, Svelte, or no framework at all. Build once, use everywhere.
  • True Encapsulation: Shadow DOM provides style and DOM encapsulation that prevents component styles from leaking out and external styles from leaking in.
  • Reusability: Create components that can be shared across different projects, teams, and organizations without framework lock-in.
  • Standardized: Based on browser standards that are supported in all modern browsers without polyfills.
  • Future-Proof: Native browser features evolve with the platform rather than with framework release cycles.
  • Interoperability: Web Components from different libraries and frameworks can work together in the same application.
  • Design Systems: Perfect for building design systems that work across multiple technology stacks.

Custom Elements

Custom Elements are the JavaScript APIs that let you define new HTML tags. You can create custom elements that extend native HTML elements or create entirely new ones. Custom elements must contain a hyphen in their name to avoid conflicts with future HTML tags.

Basic custom element definition:
// Define a new custom element
class UserCard extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        // Called when element is added to DOM
        this.render();
    }
    
    render() {
        this.shadowRoot.innerHTML = `
            
            
${this.getAttribute('name') || 'User'}
`; } static get observedAttributes() { return ['name', 'email']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { this.render(); } } } // Register the custom element customElements.define('user-card', UserCard);
Using the custom element in HTML:
<!-- Simple usage -->
<user-card name="John Doe" email="john@example.com"></user-card>

<!-- Multiple instances -->
<user-card name="Jane Smith" email="jane@example.com"></user-card>
<user-card name="Bob Johnson" email="bob@example.com"></user-card>

<!-- Dynamic creation with JavaScript -->
<script>
    const card = document.createElement('user-card');
    card.setAttribute('name', 'Alice Brown');
    card.setAttribute('email', 'alice@example.com');
    document.body.appendChild(card);
</script>

Shadow DOM

Shadow DOM is a browser technology that provides style and DOM encapsulation. It allows a component to have its own hidden DOM tree attached to an element, keeping the component's internal structure separate from the main document. This prevents CSS conflicts, ID collisions, and unintended style leakage.

  • Style Encapsulation: CSS defined inside Shadow DOM does not affect the main document, and external CSS does not style Shadow DOM internals.
  • DOM Encapsulation: Elements inside Shadow DOM are hidden from document.querySelector and other DOM traversal methods.
  • Slots: Allow content projection, enabling users to pass content into designated areas of the component.
  • Shadow Roots: The root of the shadow tree, attached to a host element.
Shadow DOM with slots:
class CustomCard extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                .card {
                    border: 1px solid #ddd;
                    border-radius: 8px;
                    padding: 16px;
                    margin: 8px;
                }
                .card-header {
                    font-weight: bold;
                    border-bottom: 1px solid #eee;
                    padding-bottom: 8px;
                    margin-bottom: 8px;
                }
                ::slotted(.highlight) {
                    background-color: yellow;
                }
            </style>
            <div class="card">
                <div class="card-header">
                    <slot name="header">Default Header</slot>
                </div>
                <div class="card-body">
                    <slot>Default content</slot>
                </div>
                <div class="card-footer">
                    <slot name="footer"></slot>
                </div>
            </div>
        `;
    }
}

customElements.define('custom-card', CustomCard);
Using Shadow DOM with slots:
<custom-card>
    <span slot="header">My Custom Header</span>
    <p>This content goes to the default slot</p>
    <p class="highlight">This paragraph has a highlight class</p>
    <div slot="footer">
        <button>Cancel</button>
        <button>Save</button>
    </div>
</custom-card>

HTML Templates

The template and slot elements work together with Shadow DOM to define reusable markup structures. The template element holds HTML content that is not rendered when the page loads but can be instantiated later. Template content is inert and does not load resources or execute scripts until activated.

HTML template example:
<!-- Define template -->
<template id="product-card-template">
    <style>
        .product-card {
            border: 1px solid #e0e0e0;
            border-radius: 8px;
            padding: 16px;
            width: 250px;
            transition: transform 0.2s;
        }
        .product-card:hover {
            transform: translateY(-4px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
        }
        .product-title {
            font-size: 1.1em;
            font-weight: bold;
            margin-bottom: 8px;
        }
        .product-price {
            color: #2e7d32;
            font-weight: bold;
        }
    </style>
    <div class="product-card">
        <div class="product-title"><slot name="title"></slot></div>
        <div class="product-price"><slot name="price"></slot></div>
        <slot></slot>
    </div>
</template>

<script>
    class ProductCard extends HTMLElement {
        constructor() {
            super();
            const template = document.getElementById('product-card-template');
            const templateContent = template.content;
            this.attachShadow({ mode: 'open' });
            this.shadowRoot.appendChild(templateContent.cloneNode(true));
        }
    }
    
    customElements.define('product-card', ProductCard);
</script>

Web Component Lifecycle Callbacks

Custom elements have lifecycle callbacks that allow you to run code at specific points in the component's life. Understanding these callbacks is essential for building robust components.

Callback When Called Use Case
constructor() When element is created (not yet added to DOM) Initialize state, attach shadow root, create internal structures
connectedCallback() When element is added to DOM Setup event listeners, fetch data, start timers, render content
disconnectedCallback() When element is removed from DOM Clean up event listeners, stop timers, release resources
attributeChangedCallback() When observed attributes change React to attribute changes, re-render, update state
adoptedCallback() When element is moved to a new document Handle adoption between frames or documents
Lifecycle callbacks implementation:
class LifecycleDemo extends HTMLElement {
    constructor() {
        super();
        console.log('1. Constructor called');
        this.attachShadow({ mode: 'open' });
        this.data = null;
        this.intervalId = null;
    }
    
    connectedCallback() {
        console.log('2. Connected to DOM');
        this.fetchData();
        this.startPolling();
        this.render();
    }
    
    disconnectedCallback() {
        console.log('3. Disconnected from DOM');
        this.stopPolling();
        this.cleanup();
    }
    
    attributeChangedCallback(name, oldValue, newValue) {
        console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
        this.render();
    }
    
    adoptedCallback() {
        console.log('Element adopted to new document');
    }
    
    static get observedAttributes() {
        return ['data-id', 'data-mode'];
    }
    
    fetchData() {
        const id = this.getAttribute('data-id');
        // Fetch data logic
    }
    
    startPolling() {
        this.intervalId = setInterval(() => {
            // Poll for updates
        }, 5000);
    }
    
    stopPolling() {
        if (this.intervalId) {
            clearInterval(this.intervalId);
            this.intervalId = null;
        }
    }
    
    render() {
        this.shadowRoot.innerHTML = `...`;
    }
    
    cleanup() {
        // Release any external resources
    }
}

customElements.define('lifecycle-demo', LifecycleDemo);

Communicating Between Components

Web Components communicate with the outside world through attributes, properties, events, and methods. Choosing the right communication pattern is important for building maintainable component hierarchies.

  • Attributes: For primitive data passed declaratively from HTML.
  • Properties: For complex data passed programmatically from JavaScript.
  • Custom Events: For notifying parent components about user actions or state changes.
  • Methods: For imperative control of the component's behavior.
  • Event Bubbling: Events can bubble through shadow DOM boundaries with the composed property.
Component communication patterns:
class TodoItem extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                .todo { display: flex; gap: 8px; padding: 8px; }
                .completed { text-decoration: line-through; opacity: 0.7; }
            </style>
            <div class="todo">
                <input type="checkbox" id="checkbox">
                <label id="label"></label>
                <button id="delete-btn">Delete</button>
            </div>
        `;
        
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        const checkbox = this.shadowRoot.getElementById('checkbox');
        const deleteBtn = this.shadowRoot.getElementById('delete-btn');
        
        checkbox.addEventListener('change', (e) => {
            this.dispatchEvent(new CustomEvent('toggle', {
                detail: { completed: e.target.checked },
                bubbles: true,
                composed: true
            }));
            this.updateDisplay();
        });
        
        deleteBtn.addEventListener('click', () => {
            this.dispatchEvent(new CustomEvent('delete', {
                bubbles: true,
                composed: true
            }));
        });
    }
    
    set data(todo) {
        this._todo = todo;
        this.updateDisplay();
    }
    
    updateDisplay() {
        const checkbox = this.shadowRoot.getElementById('checkbox');
        const label = this.shadowRoot.getElementById('label');
        
        checkbox.checked = this._todo.completed;
        label.textContent = this._todo.text;
        
        if (this._todo.completed) {
            label.classList.add('completed');
        } else {
            label.classList.remove('completed');
        }
    }
}

customElements.define('todo-item', TodoItem);

// Usage in parent component
const todoList = document.querySelector('todo-list');
todoList.addEventListener('toggle', (e) => {
    console.log('Todo toggled:', e.detail);
});
todoList.addEventListener('delete', (e) => {
    console.log('Todo deleted');
});

Styling Web Components

Styling Web Components requires understanding Shadow DOM's style boundaries. There are several ways to style components, each with different trade-offs.

  • Inline Styles in Shadow DOM: Styles defined inside Shadow DOM are fully encapsulated and do not leak out.
  • Constructable Stylesheets: Shared stylesheets that can be adopted by multiple components for better performance.
  • CSS Custom Properties: CSS variables pierce through Shadow DOM, allowing theming and customization.
  • Part and Exportparts: Allow external styling of specific parts of a shadow tree.
  • External Stylesheets: Can be imported into Shadow DOM using <link> tags.
Advanced styling patterns:
class ThemedButton extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                .btn {
                    /* Use CSS custom property for theming */
                    background-color: var(--button-bg, #007bff);
                    color: var(--button-color, white);
                    border: none;
                    padding: 10px 20px;
                    border-radius: 4px;
                    cursor: pointer;
                }
                .btn:hover {
                    opacity: 0.9;
                }
                /* Mark parts for external styling */
                .btn::part(icon) {
                    margin-right: 8px;
                }
            </style>
            <button class="btn" part="button">
                <slot name="icon"></slot>
                <slot></slot>
            </button>
        `;
    }
}

customElements.define('themed-button', ThemedButton);
External styling of components:
<!-- HTML usage -->
<themed-button id="my-btn">
    <span slot="icon">👍</span>
    Click Me
</themed-button>

<style>
    /* Theming with CSS custom properties */
    #my-btn {
        --button-bg: #28a745;
        --button-color: white;
    }
    
    /* Styling exported parts */
    themed-button::part(button) {
        font-weight: bold;
        text-transform: uppercase;
    }
</style>

Web Components in Frameworks

One of the main advantages of Web Components is framework interoperability. You can use Web Components in React, Vue, Angular, Svelte, and other frameworks. However, each framework has slightly different patterns for integration.

Web Components in React:
// React with TypeScript
import React, { useRef, useEffect } from 'react';

// Import the Web Component
import './components/user-card';

interface UserCardProps {
    name: string;
    email: string;
}

const UserCard: React.FC = ({ name, email }) => {
    const ref = useRef(null);
    
    useEffect(() => {
        if (ref.current) {
            ref.current.setAttribute('name', name);
            ref.current.setAttribute('email', email);
        }
    }, [name, email]);
    
    return <user-card ref={ref}></user-card>;
};

export default UserCard;
Web Components in Vue:
<template>
    <div>
        <!-- Vue automatically recognizes Web Components -->
        <user-card :name="user.name" :email="user.email"></user-card>
        
        <!-- For event handling, add .native modifier -->
        <todo-item @custom-event="handleEvent"></todo-item>
    </div>
</template>

<script>
import './components/user-card';
import './components/todo-item';

export default {
    data() {
        return {
            user: { name: 'John Doe', email: 'john@example.com' }
        };
    },
    methods: {
        handleEvent(event) {
            console.log('Custom event:', event.detail);
        }
    }
};
</script>
Web Components in Angular:
// app.module.ts
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';

// Import Web Components
import './components/user-card';
import './components/todo-item';

@NgModule({
    declarations: [AppComponent],
    imports: [BrowserModule],
    schemas: [CUSTOM_ELEMENTS_SCHEMA], // Allow custom elements
    bootstrap: [AppComponent]
})
export class AppModule {}

// app.component.html
<div>
    <user-card [attr.name]="user.name" [attr.email]="user.email"></user-card>
    <todo-item (customEvent)="handleEvent($event)"></todo-item>
</div>

Building a Real-World Component Library

Web Components are ideal for building design systems and component libraries that work across multiple projects and frameworks. Here is an example of building a practical component.

Complete accordion component:
class Accordion extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <style>
                .accordion {
                    border: 1px solid #ddd;
                    border-radius: 4px;
                    margin-bottom: 8px;
                }
                .accordion-header {
                    background: #f5f5f5;
                    padding: 12px 16px;
                    cursor: pointer;
                    font-weight: bold;
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                }
                .accordion-header:hover {
                    background: #e0e0e0;
                }
                .accordion-indicator {
                    transition: transform 0.3s;
                }
                .accordion-indicator.open {
                    transform: rotate(180deg);
                }
                .accordion-content {
                    max-height: 0;
                    overflow: hidden;
                    transition: max-height 0.3s ease-out;
                    padding: 0 16px;
                }
                .accordion-content.open {
                    max-height: 500px;
                    padding: 16px;
                }
            </style>
            <div class="accordion">
                <div class="accordion-header">
                    <span class="accordion-title"><slot name="title">Accordion Title</slot></span>
                    <span class="accordion-indicator">▼</span>
                </div>
                <div class="accordion-content">
                    <slot>Accordion content goes here</slot>
                </div>
            </div>
        `;
        
        this.isOpen = this.hasAttribute('open');
        this.setupEventListeners();
    }
    
    setupEventListeners() {
        const header = this.shadowRoot.querySelector('.accordion-header');
        header.addEventListener('click', () => this.toggle());
    }
    
    toggle() {
        this.isOpen = !this.isOpen;
        this.updateUI();
        this.dispatchEvent(new CustomEvent('toggle', {
            detail: { open: this.isOpen },
            bubbles: true
        }));
    }
    
    updateUI() {
        const content = this.shadowRoot.querySelector('.accordion-content');
        const indicator = this.shadowRoot.querySelector('.accordion-indicator');
        
        if (this.isOpen) {
            content.classList.add('open');
            indicator.classList.add('open');
            this.setAttribute('open', '');
        } else {
            content.classList.remove('open');
            indicator.classList.remove('open');
            this.removeAttribute('open');
        }
    }
    
    static get observedAttributes() {
        return ['open'];
    }
    
    attributeChangedCallback(name, oldValue, newValue) {
        if (name === 'open') {
            this.isOpen = newValue !== null;
            this.updateUI();
        }
    }
}

customElements.define('app-accordion', Accordion);

Common Web Component Mistakes to Avoid

Even experienced developers make mistakes when building Web Components. Being aware of these common pitfalls helps you build better components.

  • Forgetting to Use Shadow DOM: Without Shadow DOM, styles and markup are not encapsulated, leading to conflicts.
  • Using Too Many Attributes: Attributes only support string values. Use properties for complex data types like objects and arrays.
  • Memory Leaks: Forgetting to clean up event listeners and observers in disconnectedCallback causes memory leaks.
  • Not Handling Attribute Changes: Without attributeChangedCallback, component does not react to dynamic attribute updates.
  • Global Event Listeners: Attaching event listeners to window or document without cleaning up causes memory leaks.
  • Id Conflicts: IDs inside Shadow DOM are scoped, but using duplicate IDs across multiple component instances can still cause issues.
  • Overly Complex Templates: Complex inline HTML strings are hard to maintain. Use HTML templates for better organization.

Testing Web Components

Testing Web Components requires tools that can handle custom elements and Shadow DOM. Several testing frameworks work well with Web Components.

Testing with Web Test Runner:
import { expect, fixture, html } from '@open-wc/testing';
import '../components/user-card.js';

describe('UserCard', () => {
    it('renders name and email', async () => {
        const el = await fixture(html`
            <user-card name="John Doe" email="john@example.com"></user-card>
        `);
        
        const shadowRoot = el.shadowRoot;
        expect(shadowRoot).to.not.be.null;
        expect(shadowRoot.innerHTML).to.contain('John Doe');
        expect(shadowRoot.innerHTML).to.contain('john@example.com');
    });
    
    it('responds to attribute changes', async () => {
        const el = await fixture(html`
            <user-card name="Initial"></user-card>
        `);
        
        el.setAttribute('name', 'Updated');
        await el.updateComplete; // Wait for render
        
        const shadowRoot = el.shadowRoot;
        expect(shadowRoot.innerHTML).to.contain('Updated');
    });
});

Frequently Asked Questions

  1. Are Web Components supported in all browsers?
    Yes, Web Components are supported in all modern browsers including Chrome, Firefox, Safari, Edge, and Opera. IE11 requires polyfills but is no longer supported by Microsoft. All major browsers have implemented Custom Elements v1 and Shadow DOM v1.
  2. Can I use Web Components with React?
    Yes, Web Components work with React. However, there are some caveats: React handles custom element properties differently than regular DOM elements, and you may need refs to set properties. React 19 has improved Web Component support.
  3. What is the difference between Light DOM and Shadow DOM?
    Light DOM is the regular DOM tree of an element. Shadow DOM is an encapsulated DOM tree attached to an element. Content inside Shadow DOM is isolated from the main document, preventing style and selector conflicts.
  4. When should I use Web Components instead of a framework?
    Use Web Components when you need framework-agnostic components, are building a design system for multiple technology stacks, or want components that remain usable long-term without framework updates. Use frameworks for complex application state management and data binding.
  5. What should I learn next after understanding Web Components?
    After mastering Web Components, explore Lit for simplifying component development, Stencil for building component libraries, testing Web Components, and design systems for building reusable UI libraries.

Conclusion

Web Components represent a significant step forward for the web platform, providing native, standardized APIs for building reusable UI components. Unlike framework-specific components, Web Components work everywhere, across all modern browsers and with any JavaScript framework. They provide true encapsulation through Shadow DOM, extensibility through Custom Elements, and reusability through HTML Templates.

The benefits of Web Components extend beyond technical capabilities. They enable true component portability, allowing teams to share UI components across projects regardless of the underlying framework choices. Design systems built with Web Components can serve React, Angular, Vue, and vanilla JavaScript applications simultaneously. This interoperability reduces duplication of effort and ensures consistent user experiences across an organization.

As the web platform continues to evolve, Web Components will play an increasingly important role in how we build web applications. They complement rather than replace frameworks, providing a solid foundation for component architecture that works across the entire ecosystem. By mastering Web Components, you gain the ability to build truly reusable, future-proof components that will work for years to come.

To deepen your understanding, explore related topics like Lit for simplified Web Component development, testing Web Components for quality assurance, design systems for building component libraries, and Shadow DOM advanced patterns for deeper encapsulation techniques. Together, these skills form a complete foundation for building modern, interoperable UI components.