Purpose
Web components enable modular, reusable UI elements with encapsulated styles and behavior. Use these patterns when building apps that need multiple instances of the same element or when creating building blocks for other skills.
When to Use Web Components
| Scenario | Use Web Component? |
|---|---|
| Repeated UI element (cards, buttons, inputs) | Yes |
| Complex widget with internal state | Yes |
| Simple one-off element | No—use regular HTML |
| Styling only | No—use CSS classes |
Basic Custom Element
<script>
class MyCounter extends HTMLElement {
constructor() {
super();
this.count = 0;
}
connectedCallback() {
this.render();
this.querySelector('button').addEventListener('click', () => {
this.count++;
this.render();
});
}
render() {
this.innerHTML = `
<div class="counter">
<span>${this.count}</span>
<button>+1</button>
</div>
`;
}
}
customElements.define('my-counter', MyCounter);
</script>
<!-- Usage -->
<my-counter></my-counter>With Shadow DOM (Style Encapsulation)
Use Shadow DOM when component styles should not leak out or be affected by page styles:
<script></script>
<!-- Usage -->
<styled-card title="Hello">This is the card content</styled-card>Attributes and Properties
Pass data via attributes:
<script>
class UserBadge extends HTMLElement {
static get observedAttributes() {
return ['name', 'role'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
connectedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || 'Anonymous';
const role = this.getAttribute('role') || 'User';
this.innerHTML = `
<div class="badge">
<strong>${name}</strong>
<small>${role}</small>
</div>
`;
}
}
customElements.define('user-badge', UserBadge);
</script>
<!-- Usage -->
<user-badge name="Alice" role="Admin"></user-badge>Slots for Composition
Slots allow content injection:
<script></script>
<!-- Usage -->
<info-box type="warning">
<p>This action cannot be undone.</p>
</info-box>Named Slots
For complex layouts with multiple content areas:
<script></script>
<!-- Usage -->
<page-layout>
<h1 slot="header">My App</h1>
<p>Main content goes here</p>
<p slot="footer">© 2025</p>
</page-layout>Event Communication
Components communicate via custom events:
<script>
class TodoItem extends HTMLElement {
connectedCallback() {
const text = this.getAttribute('text');
const id = this.getAttribute('item-id');
this.innerHTML = `
<div class="todo-item">
<span>${text}</span>
<button class="delete">×</button>
</div>
`;
this.querySelector('.delete').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('todo-delete', {
bubbles: true,
detail: { id }
}));
});
}
}
customElements.define('todo-item', TodoItem);
// Parent listens for events
document.addEventListener('todo-delete', (e) => {
console.log('Delete item:', e.detail.id);
});
</script>Complete Example: Task List
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task List</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0f0f0f;
--surface: #1a1a1a;
--text: #e5e5e5;
--accent: #6366f1;
}
body {
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem;
}
</style>
</head>
<body>
<task-list></task-list>
<script>
class TaskItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const text = this.getAttribute('text');
const done = this.hasAttribute('done');
this.shadowRoot.innerHTML = `
<style>
:host {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #1a1a1a;
border-radius: 8px;
margin-bottom: 0.5rem;
}
input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
accent-color: #6366f1;
}
span {
flex: 1;
${done ? 'text-decoration: line-through; opacity: 0.5;' : ''}
}
button {
background: transparent;
border: none;
color: #888;
cursor: pointer;
font-size: 1.25rem;
}
button:hover { color: #ef4444; }
</style>
<input type="checkbox" ${done ? 'checked' : ''}>
<span>${text}</span>
<button>×</button>
`;
this.shadowRoot.querySelector('input').addEventListener('change', (e) => {
this.dispatchEvent(new CustomEvent('task-toggle', { bubbles: true }));
});
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('task-delete', { bubbles: true }));
});
}
}
class TaskList extends HTMLElement {
constructor() {
super();
this.tasks = [];
}
connectedCallback() {
this.render();
this.addEventListener('task-toggle', (e) => {
const item = e.target;
const index = [...this.querySelectorAll('task-item')].indexOf(item);
this.tasks[index].done = !this.tasks[index].done;
this.render();
});
this.addEventListener('task-delete', (e) => {
const item = e.target;
const index = [...this.querySelectorAll('task-item')].indexOf(item);
this.tasks.splice(index, 1);
this.render();
});
}
render() {
this.innerHTML = `
<div style="max-width: 400px; margin: 0 auto;">
<h1 style="margin-bottom: 1rem;">Tasks</h1>
<form style="display: flex; gap: 0.5rem; margin-bottom: 1rem;">
<input type="text" placeholder="Add a task..."
style="flex: 1; padding: 0.75rem; border-radius: 8px; border: 1px solid #333; background: #1a1a1a; color: #e5e5e5;">
<button type="submit"
style="padding: 0.75rem 1rem; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer;">
Add
</button>
</form>
<div class="tasks">
${this.tasks.map((t, i) =>
`<task-item text="${t.text}" ${t.done ? 'done' : ''}></task-item>`
).join('')}
</div>
</div>
`;
this.querySelector('form').addEventListener('submit', (e) => {
e.preventDefault();
const input = this.querySelector('input');
if (input.value.trim()) {
this.tasks.push({ text: input.value.trim(), done: false });
input.value = '';
this.render();
}
});
}
}
customElements.define('task-item', TaskItem);
customElements.define('task-list', TaskList);
</script>
</body>
</html>Best Practices
- Naming: Use kebab-case with a prefix:
my-,app-, or domain-specific likeviz-chart - Shadow DOM: Use for style isolation; skip if you need page styles to apply
- State: Keep state in the top-level component; pass down via attributes
- Events: Bubble up from children; listen at parent level
- Performance: Avoid re-rendering entire component; update only changed parts when possible