Web Components Complete Guide | Native Custom Elements

Web Components Complete Guide | Native Custom Elements

이 글의 핵심

Web Components are a suite of browser standards that let you create reusable custom elements with encapsulated functionality. They work everywhere without frameworks.

Introduction

Web Components are a set of web platform APIs that allow you to create custom, reusable HTML elements with encapsulated functionality. They’re built into the browser and don’t require any framework.

The Three Pillars

  1. Custom Elements - Define new HTML tags
  2. Shadow DOM - Encapsulate styles and markup
  3. HTML Templates - Reusable HTML chunks

Why Web Components?

Problem with frameworks:

// React component only works in React
function MyButton({ text }) {
  return <button>{text}</button>;
}

// Vue component only works in Vue
export default {
  template: '<button>{{ text }}</button>'
}

Web Components work everywhere:

// Works in React, Vue, Angular, or vanilla JS
class MyButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<button>${this.textContent}</button>`;
  }
}
customElements.define('my-button', MyButton);
<!-- Use anywhere -->
<my-button>Click me</my-button>

1. Custom Elements

Basic Custom Element

class HelloWorld extends HTMLElement {
  connectedCallback() {
    this.innerHTML = '<h1>Hello, World!</h1>';
  }
}

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

With Attributes

class UserCard extends HTMLElement {
  connectedCallback() {
    const name = this.getAttribute('name') || 'Guest';
    const role = this.getAttribute('role') || 'User';
    
    this.innerHTML = `
      <div class="user-card">
        <h2>${name}</h2>
        <p>${role}</p>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);
<user-card name="Alice" role="Developer"></user-card>

Lifecycle Callbacks

class MyElement extends HTMLElement {
  // Called when element is created
  constructor() {
    super();
    console.log('Constructor');
  }
  
  // Called when added to DOM
  connectedCallback() {
    console.log('Connected');
    this.render();
  }
  
  // Called when removed from DOM
  disconnectedCallback() {
    console.log('Disconnected');
  }
  
  // Called when moved to new page
  adoptedCallback() {
    console.log('Adopted');
  }
  
  // Called when attribute changes
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`${name} changed from ${oldValue} to ${newValue}`);
    this.render();
  }
  
  // Specify which attributes to observe
  static get observedAttributes() {
    return ['name', 'age'];
  }
  
  render() {
    const name = this.getAttribute('name');
    this.innerHTML = `<p>Hello, ${name}!</p>`;
  }
}

customElements.define('my-element', MyElement);

2. Shadow DOM

Shadow DOM provides encapsulation - styles and markup hidden from the main document.

Basic Shadow DOM

class ShadowCard extends HTMLElement {
  connectedCallback() {
    // Attach shadow root
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        /* Styles scoped to shadow DOM */
        .card {
          border: 1px solid #ccc;
          padding: 1rem;
          border-radius: 8px;
        }
        h2 { color: blue; }
      </style>
      <div class="card">
        <h2>Shadow Card</h2>
        <p>This is encapsulated!</p>
      </div>
    `;
  }
}

customElements.define('shadow-card', ShadowCard);

Benefits:

  • Styles don’t leak out
  • External styles don’t leak in
  • DOM structure hidden

Slots (Content Projection)

class FancyButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        button {
          background: linear-gradient(45deg, #667eea, #764ba2);
          color: white;
          border: none;
          padding: 12px 24px;
          border-radius: 8px;
          cursor: pointer;
        }
      </style>
      <button>
        <slot></slot>
      </button>
    `;
  }
}

customElements.define('fancy-button', FancyButton);
<fancy-button>Click Me!</fancy-button>

Named Slots

class UserProfile extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    
    shadow.innerHTML = `
      <style>
        .profile {
          display: flex;
          gap: 1rem;
          padding: 1rem;
          border: 1px solid #ddd;
        }
        .avatar { width: 64px; height: 64px; }
      </style>
      <div class="profile">
        <slot name="avatar"></slot>
        <div>
          <slot name="name"></slot>
          <slot name="bio"></slot>
        </div>
      </div>
    `;
  }
}

customElements.define('user-profile', UserProfile);
<user-profile>
  <img slot="avatar" src="avatar.jpg">
  <h2 slot="name">Alice</h2>
  <p slot="bio">Software Developer</p>
</user-profile>

3. HTML Templates

<template id="user-card-template">
  <style>
    .card {
      border: 1px solid #ccc;
      padding: 1rem;
      margin: 0.5rem;
    }
  </style>
  <div class="card">
    <h3></h3>
    <p></p>
  </div>
</template>

<script>
class UserCard extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('user-card-template');
    const clone = template.content.cloneNode(true);
    
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(clone);
    
    shadow.querySelector('h3').textContent = this.getAttribute('name');
    shadow.querySelector('p').textContent = this.getAttribute('role');
  }
}

customElements.define('user-card', UserCard);
</script>

4. Properties and Methods

class Counter extends HTMLElement {
  constructor() {
    super();
    this._count = 0;
    this.attachShadow({ mode: 'open' });
  }
  
  // Getter/setter for count property
  get count() {
    return this._count;
  }
  
  set count(value) {
    this._count = value;
    this.render();
  }
  
  // Public method
  increment() {
    this.count++;
  }
  
  connectedCallback() {
    this.render();
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      this.increment();
    });
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button { padding: 8px 16px; }
      </style>
      <div>
        <p>Count: ${this.count}</p>
        <button>Increment</button>
      </div>
    `;
  }
}

customElements.define('my-counter', Counter);
// Use from JavaScript
const counter = document.querySelector('my-counter');
counter.count = 10;
counter.increment();

5. Events

Dispatching Custom Events

class TodoItem extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.render();
    
    this.shadowRoot.querySelector('button').addEventListener('click', () => {
      // Dispatch custom event
      this.dispatchEvent(new CustomEvent('todo-complete', {
        detail: { id: this.getAttribute('id') },
        bubbles: true,
        composed: true // Cross shadow boundary
      }));
    });
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <div>
        <span>${this.getAttribute('text')}</span>
        <button>Complete</button>
      </div>
    `;
  }
}

customElements.define('todo-item', TodoItem);
// Listen for custom event
document.addEventListener('todo-complete', (e) => {
  console.log('Todo completed:', e.detail.id);
});

6. Real-World Example: Modal Dialog

class ModalDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  
  connectedCallback() {
    this.render();
    
    // Close on backdrop click
    this.shadowRoot.querySelector('.backdrop').addEventListener('click', () => {
      this.close();
    });
    
    // Close on close button
    this.shadowRoot.querySelector('.close').addEventListener('click', () => {
      this.close();
    });
  }
  
  open() {
    this.style.display = 'block';
    document.body.style.overflow = 'hidden';
  }
  
  close() {
    this.style.display = 'none';
    document.body.style.overflow = '';
    this.dispatchEvent(new Event('modal-closed'));
  }
  
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: none;
          position: fixed;
          inset: 0;
          z-index: 1000;
        }
        
        .backdrop {
          position: absolute;
          inset: 0;
          background: rgba(0, 0, 0, 0.5);
        }
        
        .modal {
          position: relative;
          max-width: 500px;
          margin: 5rem auto;
          background: white;
          border-radius: 8px;
          padding: 2rem;
        }
        
        .close {
          position: absolute;
          top: 1rem;
          right: 1rem;
          background: none;
          border: none;
          font-size: 1.5rem;
          cursor: pointer;
        }
      </style>
      
      <div class="backdrop"></div>
      <div class="modal">
        <button class="close">&times;</button>
        <slot></slot>
      </div>
    `;
  }
}

customElements.define('modal-dialog', ModalDialog);
<button onclick="document.querySelector('modal-dialog').open()">
  Open Modal
</button>

<modal-dialog>
  <h2>Modal Title</h2>
  <p>Modal content goes here...</p>
</modal-dialog>

7. Best Practices

1. Use Semantic Tag Names

// Good: descriptive, kebab-case
customElements.define('user-profile', UserProfile);
customElements.define('todo-list', TodoList);

// Bad: too generic
customElements.define('my-element', MyElement);

2. Always Use Shadow DOM for Encapsulation

// Good: styles encapsulated
class MyButton extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<style>...</style><button>...</button>`;
  }
}

// Bad: styles leak to global scope
class MyButton extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<style>...</style><button>...</button>`;
  }
}

3. Clean Up Event Listeners

class MyElement extends HTMLElement {
  connectedCallback() {
    this.handleClick = () => console.log('Clicked');
    this.addEventListener('click', this.handleClick);
  }
  
  disconnectedCallback() {
    this.removeEventListener('click', this.handleClick);
  }
}

8. Framework Integration

React

function App() {
  return (
    <div>
      <user-card name="Alice" role="Developer"></user-card>
    </div>
  );
}

Vue

<template>
  <user-card name="Alice" role="Developer"></user-card>
</template>

Angular

import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

Summary

Web Components enable framework-agnostic reusable components:

  • Custom Elements for new HTML tags
  • Shadow DOM for style encapsulation
  • HTML Templates for reusable markup
  • Native browser APIs - no build tools required
  • Works everywhere - any framework or vanilla JS

Key Takeaways:

  1. Use customElements.define() to register
  2. Shadow DOM for encapsulation
  3. Lifecycle callbacks for component logic
  4. Dispatch custom events for communication
  5. Compatible with all modern frameworks

Next Steps:

  • Build with Lit
  • Learn Vanilla JavaScript
  • Try Stencil

Resources: