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
- Custom Elements - Define new HTML tags
- Shadow DOM - Encapsulate styles and markup
- 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">×</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:
- Use
customElements.define()to register - Shadow DOM for encapsulation
- Lifecycle callbacks for component logic
- Dispatch custom events for communication
- Compatible with all modern frameworks
Next Steps:
- Build with Lit
- Learn Vanilla JavaScript
- Try Stencil
Resources: