Web Components create reusable, encapsulated HTML elements that work in any framework.
Basic Custom Element#
1class MyButton extends HTMLElement {
2 constructor() {
3 super();
4 this.attachShadow({ mode: 'open' });
5 }
6
7 connectedCallback() {
8 this.render();
9 }
10
11 render() {
12 this.shadowRoot.innerHTML = `
13 <style>
14 button {
15 padding: 0.5rem 1rem;
16 background: #3b82f6;
17 color: white;
18 border: none;
19 border-radius: 4px;
20 cursor: pointer;
21 }
22 button:hover {
23 background: #2563eb;
24 }
25 </style>
26 <button>
27 <slot></slot>
28 </button>
29 `;
30 }
31}
32
33customElements.define('my-button', MyButton);<my-button>Click Me</my-button>Attributes and Properties#
1class UserCard extends HTMLElement {
2 static get observedAttributes() {
3 return ['name', 'avatar', 'role'];
4 }
5
6 constructor() {
7 super();
8 this.attachShadow({ mode: 'open' });
9 }
10
11 connectedCallback() {
12 this.render();
13 }
14
15 attributeChangedCallback(name, oldValue, newValue) {
16 if (oldValue !== newValue) {
17 this.render();
18 }
19 }
20
21 get name() {
22 return this.getAttribute('name') || 'Unknown';
23 }
24
25 get avatar() {
26 return this.getAttribute('avatar') || '/default-avatar.png';
27 }
28
29 get role() {
30 return this.getAttribute('role') || 'Member';
31 }
32
33 render() {
34 this.shadowRoot.innerHTML = `
35 <style>
36 .card {
37 display: flex;
38 gap: 1rem;
39 padding: 1rem;
40 border: 1px solid #e5e7eb;
41 border-radius: 8px;
42 }
43 img {
44 width: 64px;
45 height: 64px;
46 border-radius: 50%;
47 }
48 h3 { margin: 0; }
49 p { margin: 0.25rem 0 0; color: #6b7280; }
50 </style>
51 <div class="card">
52 <img src="${this.avatar}" alt="${this.name}">
53 <div>
54 <h3>${this.name}</h3>
55 <p>${this.role}</p>
56 </div>
57 </div>
58 `;
59 }
60}
61
62customElements.define('user-card', UserCard);Custom Events#
1class CounterElement extends HTMLElement {
2 constructor() {
3 super();
4 this.attachShadow({ mode: 'open' });
5 this._count = 0;
6 }
7
8 connectedCallback() {
9 this.render();
10 this.shadowRoot.querySelector('button.increment')
11 .addEventListener('click', () => this.increment());
12 this.shadowRoot.querySelector('button.decrement')
13 .addEventListener('click', () => this.decrement());
14 }
15
16 increment() {
17 this._count++;
18 this.update();
19 this.dispatchEvent(new CustomEvent('count-changed', {
20 detail: { count: this._count },
21 bubbles: true,
22 composed: true, // Crosses shadow DOM boundary
23 }));
24 }
25
26 decrement() {
27 this._count--;
28 this.update();
29 this.dispatchEvent(new CustomEvent('count-changed', {
30 detail: { count: this._count },
31 bubbles: true,
32 composed: true,
33 }));
34 }
35
36 update() {
37 this.shadowRoot.querySelector('.count').textContent = this._count;
38 }
39
40 render() {
41 this.shadowRoot.innerHTML = `
42 <style>
43 .counter { display: flex; gap: 1rem; align-items: center; }
44 button { padding: 0.5rem 1rem; }
45 </style>
46 <div class="counter">
47 <button class="decrement">-</button>
48 <span class="count">${this._count}</span>
49 <button class="increment">+</button>
50 </div>
51 `;
52 }
53}
54
55customElements.define('my-counter', CounterElement);1<my-counter></my-counter>
2<script>
3 document.querySelector('my-counter')
4 .addEventListener('count-changed', (e) => {
5 console.log('Count:', e.detail.count);
6 });
7</script>Slots for Content Projection#
1class CardElement extends HTMLElement {
2 constructor() {
3 super();
4 this.attachShadow({ mode: 'open' });
5 this.shadowRoot.innerHTML = `
6 <style>
7 .card { border: 1px solid #e5e7eb; border-radius: 8px; }
8 .header { padding: 1rem; border-bottom: 1px solid #e5e7eb; }
9 .body { padding: 1rem; }
10 .footer { padding: 1rem; border-top: 1px solid #e5e7eb; }
11 </style>
12 <div class="card">
13 <div class="header">
14 <slot name="header"></slot>
15 </div>
16 <div class="body">
17 <slot></slot>
18 </div>
19 <div class="footer">
20 <slot name="footer"></slot>
21 </div>
22 </div>
23 `;
24 }
25}
26
27customElements.define('card-element', CardElement);<card-element>
<h2 slot="header">Card Title</h2>
<p>This is the main content.</p>
<button slot="footer">Action</button>
</card-element>Form-Associated Custom Elements#
1class CustomInput extends HTMLElement {
2 static formAssociated = true;
3
4 constructor() {
5 super();
6 this.internals = this.attachInternals();
7 this.attachShadow({ mode: 'open' });
8 }
9
10 connectedCallback() {
11 this.shadowRoot.innerHTML = `
12 <input type="text" />
13 `;
14
15 this.shadowRoot.querySelector('input')
16 .addEventListener('input', (e) => {
17 this.internals.setFormValue(e.target.value);
18 });
19 }
20
21 get value() {
22 return this.shadowRoot.querySelector('input').value;
23 }
24}
25
26customElements.define('custom-input', CustomInput);Using with Frameworks#
1// React wrapper
2import { useRef, useEffect } from 'react';
3
4function MyButtonWrapper({ onClick, children }) {
5 const ref = useRef<HTMLElement>(null);
6
7 useEffect(() => {
8 const element = ref.current;
9 element?.addEventListener('click', onClick);
10 return () => element?.removeEventListener('click', onClick);
11 }, [onClick]);
12
13 return <my-button ref={ref}>{children}</my-button>;
14}Web Components provide framework-agnostic, encapsulated, reusable components.