coderain guide

How to Create Custom Elements with JavaScript and Web Components

In the modern web development landscape, building reusable, encapsulated, and framework-agnostic UI components is more critical than ever. Enter **Web Components**—a set of native browser APIs that let you create your own custom HTML elements. Whether you’re working with React, Vue, Angular, or vanilla JavaScript, Web Components work seamlessly across frameworks, making them a powerful tool for standardizing UI across projects. In this guide, we’ll demystify Web Components and walk through creating custom elements from scratch. By the end, you’ll understand how to build reusable components with encapsulated styles and behavior, and integrate them into any web application.

Table of Contents

  1. What Are Web Components?
  2. Core Concepts of Web Components
  3. Creating Your First Custom Element
  4. Lifecycle Callbacks
  5. Using Shadow DOM for Encapsulation
  6. HTML Templates and Slots for Flexible Content
  7. Advanced Features
  8. Best Practices
  9. Conclusion
  10. References

What Are Web Components?

Web Components are a collection of four W3C standards that enable the creation of reusable, self-contained HTML elements. They work natively in modern browsers (Chrome, Firefox, Safari, Edge) without needing frameworks or libraries. The key standards are:

  • Custom Elements: Define new HTML element types (e.g., <user-profile>).
  • Shadow DOM: Encapsulate styles and markup to prevent conflicts with the rest of the page.
  • HTML Templates: Define inert markup that can be cloned and reused.
  • ES Modules: Package and import components across projects.

Together, these standards let you build components that are:

  • Reusable: Use them across projects and frameworks.
  • Encapsulated: Styles and logic don’t leak into or from the component.
  • Native: No runtime dependencies—just vanilla JavaScript.

Core Concepts of Web Components

Before diving into creation, let’s break down the foundational concepts:

1. Custom Elements

Custom Elements let you define new HTML elements with custom behavior. There are two types:

  • Autonomous Custom Elements: Standalone elements that don’t extend existing HTML elements (e.g., <my-component>).
  • Customized Built-in Elements: Extend native HTML elements (e.g., <button is="fancy-button"> to enhance a <button>).

Custom element names must include a hyphen (e.g., <user-card>, not <usercard>) to avoid conflicts with future HTML standards.

2. Shadow DOM

Shadow DOM is a subtree attached to a custom element that is isolated from the main DOM. It encapsulates:

  • Markup: The component’s internal HTML.
  • Styles: CSS defined in the shadow DOM only affects the component, and external styles don’t leak in (unless explicitly allowed).

To attach a shadow DOM to an element, use element.attachShadow({ mode: 'open' }), where mode: 'open' allows external JavaScript to access the shadow root (via element.shadowRoot), and mode: 'closed' restricts access.

3. HTML Templates & Slots

  • <template>: A container for inert markup that isn’t rendered until cloned. Use <template id="my-template"> to define reusable markup, then clone it with template.content.cloneNode(true).
  • <slot>: A placeholder for content passed into the component (similar to “props” in frameworks). Slots let users customize a component’s content while keeping its structure intact.

4. ES Modules

Web Components are typically packaged as ES Modules (.js files) using export and import. This makes them easy to share across projects and integrate into build tools like Webpack or Vite.

Creating Your First Custom Element

Let’s build a simple autonomous custom element: <hello-world>, which displays a greeting and accepts a name attribute to personalize it.

Step 1: Define the Element Class

Custom elements are defined by extending HTMLElement (or a built-in element like HTMLButtonElement for customized built-ins). The class contains the element’s logic, including lifecycle callbacks and methods.

// hello-world.js
class HelloWorld extends HTMLElement {
  constructor() {
    super(); // Always call super() first in the constructor
    this.attachShadow({ mode: 'open' }); // Attach shadow DOM
  }

  // Called when the element is added to the DOM
  connectedCallback() {
    const name = this.getAttribute('name') || 'Guest';
    this.shadowRoot.innerHTML = `
      <style>
        .greeting { color: #2c3e50; font-family: Arial, sans-serif; }
      </style>
      <div class="greeting">Hello, ${name}!</div>
    `;
  }
}

Step 2: Register the Element

To make the browser recognize your custom element, register it with customElements.define(), specifying the element name and class:

// Register the element
customElements.define('hello-world', HelloWorld);

Step 3: Use the Element in HTML

Now you can use <hello-world> like any native HTML element. Pass a name attribute to customize the greeting:

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Custom Elements Demo</title>
  <script type="module" src="hello-world.js"></script> <!-- Import the module -->
</head>
<body>
  <hello-world name="Alice"></hello-world> <!-- Renders: "Hello, Alice!" -->
  <hello-world></hello-world> <!-- Renders: "Hello, Guest!" -->
</body>
</html>

How It Works:

  • The HelloWorld class extends HTMLElement, inheriting all properties/methods of a standard HTML element.
  • constructor() initializes the element and attaches an open shadow root.
  • connectedCallback() runs when the element is added to the DOM. It reads the name attribute, defines shadow DOM styles, and sets the content.

Lifecycle Callbacks

Custom elements have lifecycle callbacks that trigger at specific stages, letting you hook into their creation, update, and destruction. Here are the key ones:

constructor()

  • Triggered: When the element is created (e.g., with document.createElement('hello-world')).
  • Purpose: Initialize state, set up event listeners, attach shadow DOM.
  • Rules: Must call super() first. Avoid adding DOM content here (use connectedCallback instead, as the element may not be in the DOM yet).
constructor() {
  super();
  this.isActive = false; // Initialize state
  this.shadowRoot = this.attachShadow({ mode: 'open' });
}

connectedCallback()

  • Triggered: When the element is added to the DOM (including re-additions after removal).
  • Purpose: Render content, fetch data, start timers, or set up subscriptions.
connectedCallback() {
  this.render(); // Update shadow DOM
  this.addEventListener('click', this.handleClick);
}

disconnectedCallback()

  • Triggered: When the element is removed from the DOM.
  • Purpose: Clean up resources (e.g., event listeners, timers, subscriptions) to prevent memory leaks.
disconnectedCallback() {
  this.removeEventListener('click', this.handleClick);
}

attributeChangedCallback(attrName, oldValue, newValue)

  • Triggered: When an observed attribute is added, removed, or modified.
  • Requirement: Define static get observedAttributes() to specify which attributes to watch.
class HelloWorld extends HTMLElement {
  static get observedAttributes() {
    return ['name']; // Watch for changes to the "name" attribute
  }

  attributeChangedCallback(attrName, oldValue, newValue) {
    if (attrName === 'name' && oldValue !== newValue) {
      this.render(); // Re-render when "name" changes
    }
  }

  render() {
    const name = this.getAttribute('name') || 'Guest';
    this.shadowRoot.innerHTML = `<div>Hello, ${name}!</div>`;
  }
}

adoptedCallback()

  • Triggered: When the element is moved to a new document (e.g., an <iframe>).
  • Use Case: Rare, but useful for cross-document state synchronization.

Using Shadow DOM for Encapsulation

Shadow DOM is what makes Web Components truly encapsulated. Let’s enhance our <hello-world> component with shadow DOM styles to see how encapsulation works.

Example: Encapsulated Styles

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

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        /* This style only affects the shadow DOM */
        .greeting { 
          color: #3498db; 
          font-size: 2rem; 
          padding: 1rem; 
          border: 2px solid #3498db; 
          border-radius: 8px; 
        }
      </style>
      <div class="greeting">Hello, ${this.getAttribute('name') || 'Guest'}!</div>
    `;
  }
}

customElements.define('hello-world', HelloWorld);

Key Takeaway:

If you add a global style like .greeting { color: red; } to the main page, it won’t affect the <hello-world> component—its shadow DOM styles take precedence. Conversely, the component’s .greeting style won’t leak out to other elements on the page.

HTML Templates and Slots for Flexible Content

Templates and slots let you define reusable markup and inject custom content into components. Let’s build a <user-card> component that accepts custom avatar, name, and bio via slots.

Step 1: Define a Template

First, define a <template> in your HTML (or import it via JavaScript):

<!-- index.html -->
<template id="user-card-template">
  <style>
    .card { 
      width: 300px; 
      border: 1px solid #e0e0e0; 
      border-radius: 8px; 
      padding: 1.5rem; 
      font-family: sans-serif; 
    }
    .avatar { 
      width: 100px; 
      height: 100px; 
      border-radius: 50%; 
      object-fit: cover; 
    }
    .name { font-size: 1.5rem; margin: 1rem 0 0.5rem; }
    .bio { color: #666; }
  </style>
  <div class="card">
    <!-- Slot for avatar -->
    <slot name="avatar">
      <!-- Fallback content if no avatar is provided -->
      <img class="avatar" src="default-avatar.png" alt="Default avatar">
    </slot>
    <h2 class="name"><slot name="name">Guest</slot></h2>
    <p class="bio"><slot name="bio">No bio provided.</slot></p>
  </div>
</template>

Step 2: Create the Custom Element

Now, clone the template and attach it to the shadow DOM of <user-card>:

// user-card.js
class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    // Clone the template content
    const template = document.getElementById('user-card-template');
    const clone = template.content.cloneNode(true);
    this.shadowRoot.appendChild(clone);
  }
}

customElements.define('user-card', UserCard);

Step 3: Use Slots to Inject Content

Users of <user-card> can now pass custom content into the named slots:

<user-card>
  <img slot="avatar" class="avatar" src="alice.jpg" alt="Alice">
  <span slot="name">Alice Smith</span>
  <p slot="bio">Frontend developer passionate about Web Components and accessibility.</p>
</user-card>

How It Works:

  • The <template> defines the component’s structure and styles.
  • Slots (name="avatar", name="name", etc.) act as placeholders for user-provided content.
  • If no content is provided for a slot, the template’s fallback content (e.g., the default avatar) is used.

Advanced Features

Custom Events

Components often need to communicate with their parent. Use custom events to emit data from a Web Component.

Example: A <like-button> that emits a like-changed event when clicked:

class LikeButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.isLiked = false;
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.isLiked = !this.isLiked;
      this.render();
      // Emit a custom event with the new state
      this.dispatchEvent(new CustomEvent('like-changed', {
        detail: { isLiked: this.isLiked },
        bubbles: true, // Let the event bubble up the DOM
        cancelable: true
      }));
    });
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button { 
          padding: 0.5rem 1rem; 
          border: none; 
          border-radius: 4px; 
          cursor: pointer; 
        }
        .liked { background: #e74c3c; color: white; }
        .not-liked { background: #ecf0f1; }
      </style>
      <button class="${this.isLiked ? 'liked' : 'not-liked'}">
        ${this.isLiked ? '❤️ Liked' : '🤍 Like'}
      </button>
    `;
  }
}

customElements.define('like-button', LikeButton);

Parent components can listen to the event:

<like-button></like-button>

<script>
  document.querySelector('like-button').addEventListener('like-changed', (e) => {
    console.log('Like state:', e.detail.isLiked); // Logs true/false
  });
</script>

Extending Built-in Elements

You can extend native HTML elements (e.g., <button>, <input>) to add custom behavior. Use the extends option in customElements.define().

Example: A <fancy-button> that extends <button>:

class FancyButton extends HTMLButtonElement {
  constructor() {
    super(); // Call the parent constructor (HTMLButtonElement)
    this.style.backgroundColor = '#3498db';
    this.style.color = 'white';
    this.style.padding = '0.75rem 1.5rem';
    this.style.borderRadius = '4px';
    this.style.border = 'none';
  }
}

// Register as a customized built-in element
customElements.define('fancy-button', FancyButton, { extends: 'button' });

Use it in HTML with the is attribute:

<button is="fancy-button">Click Me</button>

Best Practices

To build robust Web Components, follow these best practices:

  1. Use Kebab-Case Names: Ensure element names have a hyphen (e.g., <user-card>, not <UserCard>) to avoid conflicts with native elements.
  2. Encapsulate with Shadow DOM: Always use shadow DOM to prevent style and markup leaks.
  3. Optimize Rendering: Avoid heavy DOM updates in connectedCallback—use requestAnimationFrame for complex renders.
  4. Handle Accessibility: Use ARIA roles (role="button"), labels, and keyboard navigation (e.g., tabindex).
  5. Test Across Browsers: While modern browsers support Web Components, test edge cases (e.g., Safari’s shadow DOM quirks).
  6. Document APIs: Clearly document attributes, events, and slots so users know how to interact with your component.

Conclusion

Web Components empower you to build reusable, encapsulated, and framework-agnostic UI elements using native browser APIs. By combining Custom Elements, Shadow DOM, Templates, and ES Modules, you can create components that work seamlessly across projects and tools.

Start small—build a simple component like a <alert-box> or <star-rating>, then experiment with advanced features like custom events and slots. With Web Components, the web becomes your canvas for consistent, maintainable UI.

References