Developing Web Components (Custom Elements) for a Website
Web Components—a set of native browser APIs that allow creating reusable HTML elements with encapsulated logic and styles. Without frameworks. Works in any HTML context: WordPress, Laravel Blade, Twig, Hugo, vanilla HTML.
Three components: Custom Elements API (registering a new tag), Shadow DOM (style encapsulation), HTML Templates (templating). Used independently or together.
Custom Elements: basics
class ToastNotification extends HTMLElement {
private shadow: ShadowRoot
private messageEl: HTMLElement | null = null
// List of attributes whose changes are tracked
static get observedAttributes() {
return ['type', 'message', 'duration']
}
constructor() {
super()
// attachShadow creates Shadow DOM
this.shadow = this.attachShadow({ mode: 'open' })
}
// Called when element is added to DOM
connectedCallback() {
this.render()
const duration = parseInt(this.getAttribute('duration') || '3000')
if (duration > 0) {
setTimeout(() => this.dismiss(), duration)
}
}
// Called when element is removed from DOM
disconnectedCallback() {
this.messageEl?.removeEventListener('click', this.dismiss)
}
// Called when tracked attribute changes
attributeChangedCallback(name: string, oldVal: string, newVal: string) {
if (oldVal !== newVal && this.isConnected) {
this.render()
}
}
private render() {
const type = this.getAttribute('type') || 'info'
const message = this.getAttribute('message') || ''
this.shadow.innerHTML = `
<style>
:host {
display: block;
font-family: inherit;
}
.toast {
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
cursor: pointer;
animation: slide-in 0.3s ease;
}
.toast--info { background: #1a1a2e; color: #7eb8f7; border: 1px solid #2a4a7f; }
.toast--success { background: #0d2e1a; color: #5cb85c; border: 1px solid #1a5e30; }
.toast--error { background: #2e0d0d; color: #e74c3c; border: 1px solid #7f1a1a; }
@keyframes slide-in {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
<div class="toast toast--${type}" part="toast">
${message}
</div>
`
this.messageEl = this.shadow.querySelector('.toast')
this.messageEl?.addEventListener('click', this.dismiss)
}
private dismiss = () => {
// Dispatch custom event — parent elements can listen
this.dispatchEvent(new CustomEvent('toast-dismiss', {
bubbles: true,
composed: true, // pierces Shadow DOM boundary
}))
this.remove()
}
// Public method — called from external JS
show(message: string, type = 'info') {
this.setAttribute('message', message)
this.setAttribute('type', type)
if (!this.isConnected) {
document.body.appendChild(this)
}
}
}
// Registration: name must contain a dash
customElements.define('toast-notification', ToastNotification)
Usage:
<!-- In HTML -->
<toast-notification type="success" message="Saved" duration="4000"></toast-notification>
<script>
// Via JS
const toast = document.createElement('toast-notification')
toast.setAttribute('type', 'error')
toast.setAttribute('message', 'Something went wrong')
document.body.appendChild(toast)
// Or via public method (if element already registered)
const existing = document.querySelector('toast-notification')
existing.show('Data loaded', 'success')
</script>
TypeScript typing
TypeScript doesn't know about custom elements—need declarations:
// types/custom-elements.d.ts
declare global {
interface HTMLElementTagNameMap {
'toast-notification': ToastNotification
'dropdown-menu': DropdownMenu
'modal-dialog': ModalDialog
}
namespace JSX {
interface IntrinsicElements {
'toast-notification': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
type?: 'info' | 'success' | 'error'
message?: string
duration?: string
},
HTMLElement
>
}
}
}
export {}
Full lifecycle
class AdvancedElement extends HTMLElement {
static get observedAttributes() { return ['src', 'lazy']; }
// Lifecycle:
constructor() {
super()
// Only Shadow DOM and handler initialization
// Don't access attributes — they don't exist yet
}
connectedCallback() {
// Element added to DOM
// Safe to read attributes and access children
this.initialize()
}
disconnectedCallback() {
// Cleanup: unsubscribe, cancelAnimationFrame, WeakRef cleanup
this.cleanup()
}
adoptedCallback() {
// Element moved to another document (rare)
this.reinitialize()
}
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
if (!this.isConnected) return // ignore before connectedCallback
this.onAttributeChange(name, oldVal, newVal)
}
}
Example: Accordion component
class AccordionItem extends HTMLElement {
private header!: HTMLElement
private content!: HTMLElement
private isOpen = false
connectedCallback() {
this.innerHTML = `
<button class="accordion-header" aria-expanded="false">
<slot name="header"></slot>
<svg class="accordion-icon" viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
<div class="accordion-content" role="region" hidden>
<slot name="content"></slot>
</div>
`
this.header = this.querySelector('.accordion-header')!
this.content = this.querySelector('.accordion-content')!
this.header.addEventListener('click', this.toggle)
}
private toggle = () => {
this.isOpen = !this.isOpen
this.header.setAttribute('aria-expanded', String(this.isOpen))
if (this.isOpen) {
this.content.hidden = false
this.content.style.maxHeight = '0'
requestAnimationFrame(() => {
this.content.style.maxHeight = this.content.scrollHeight + 'px'
})
} else {
this.content.style.maxHeight = '0'
this.content.addEventListener('transitionend', () => {
if (!this.isOpen) this.content.hidden = true
}, { once: true })
}
}
disconnectedCallback() {
this.header?.removeEventListener('click', this.toggle)
}
}
customElements.define('accordion-item', AccordionItem)
<accordion-item>
<span slot="header">How does delivery work?</span>
<div slot="content">
<p>We deliver nationwide within 3–5 business days.</p>
</div>
</accordion-item>
When Shadow DOM is not needed
Shadow DOM adds complexity. For simple components without style conflicts, Light DOM is enough:
class SimpleCounter extends HTMLElement {
private count = 0
connectedCallback() {
this.count = parseInt(this.getAttribute('initial') || '0')
this.render()
}
private render() {
this.innerHTML = `
<button class="counter-btn counter-btn--dec">-</button>
<span class="counter-value">${this.count}</span>
<button class="counter-btn counter-btn--inc">+</button>
`
this.querySelector('.counter-btn--inc')!.addEventListener('click', () => {
this.count++
this.querySelector('.counter-value')!.textContent = String(this.count)
this.dispatchEvent(new CustomEvent('change', { detail: this.count, bubbles: true }))
})
this.querySelector('.counter-btn--dec')!.addEventListener('click', () => {
this.count--
this.querySelector('.counter-value')!.textContent = String(this.count)
this.dispatchEvent(new CustomEvent('change', { detail: this.count, bubbles: true }))
})
}
}
Timeline
One custom element without Shadow DOM — 4–8 hours. Library of 5–10 components with TypeScript, tests and documentation — 1–2 weeks.







