Back to Blog
Web ComponentsCustom ElementsShadow DOMJavaScript

Web Components: Building Custom Elements

Create reusable web components with Custom Elements and Shadow DOM. Framework-agnostic UI components.

B
Bootspring Team
Engineering
February 27, 2026
3 min read

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.

Share this article

Help spread the word about Bootspring