Understanding HTML Custom Elements
Custom elements are a cornerstone of the Web Components suite, allowing developers to define their own fully-functional HTML elements with encapsulated behavior and rendering. Unlike framework-specific components, custom elements are native to the browser — they work everywhere HTML works, without dependencies or build steps.
What Are Custom Elements?
At their core, custom elements are classes that extend HTMLElement and register themselves with the browser via customElements.define(). Once registered, you can use them just like any standard HTML tag — drop them into markup, attach event listeners, style them with CSS, and query them with document.querySelector(). The browser treats them as first-class citizens of the DOM.
Two types of custom elements exist:
- Autonomous custom elements — standalone elements that extend
HTMLElementdirectly and use a hyphenated tag name like<user-avatar> - Customized built-in elements — elements that extend existing HTML elements like
HTMLButtonElement, used with theisattribute (e.g.,<button is="fancy-button">), though support for this pattern is limited and controversial
Why Custom Elements Matter
Custom elements solve real problems in modern web development. They provide a standards-based way to package UI logic that is framework-agnostic, portable, and long-lived. A custom element you build today will work in any framework or vanilla JS context ten years from now — because it relies on the web platform, not a library's release cycle.
Key benefits include:
- Interoperability — use the same component in React, Vue, Svelte, or plain HTML without adapter code
- Encapsulation — when combined with Shadow DOM, styles and DOM structure are isolated from the page
- Progressive enhancement — custom elements degrade gracefully; the browser simply ignores unrecognized tags until the script loads
- Zero dependencies — no runtime, no compiler, no build tooling required
- Small payloads — a fully functional custom element can be under 1KB gzipped
Getting Started: Defining a Custom Element
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The minimal recipe is straightforward: write a class, register it, and use the tag in your HTML. The class must extend HTMLElement (or a more specific HTML interface), and the tag name must contain a hyphen to avoid clashes with future standard elements.
A Basic Example
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Element Demo</title>
</head>
<body>
<greeting-banner></greeting-banner>
<script src="greeting-banner.js"></script>
</body>
</html>
// greeting-banner.js
class GreetingBanner extends HTMLElement {
constructor() {
super();
// Constructor logic runs when the element is created
// (before it's connected to the DOM)
}
connectedCallback() {
// Called when the element is inserted into the DOM
this.textContent = 'Hello, welcome to our site!';
this.setAttribute('role', 'status');
this.style.cssText = `
display: block;
padding: 1rem;
background: #4a90d9;
color: white;
font-family: sans-serif;
border-radius: 6px;
`;
}
}
// Register the element with the browser
customElements.define('greeting-banner', GreetingBanner);
Load this in a browser, and the <greeting-banner> tag renders as a styled banner. No framework, no compilation — just a JavaScript file and standard HTML.
Lifecycle Callbacks
Custom elements come with a set of lifecycle hooks that let you respond to key moments in an element's existence. Understanding these is essential for building robust components.
class LifecycleDemo extends HTMLElement {
constructor() {
super();
console.log('constructor: element created, may not be in DOM yet');
// Don't inspect children or attributes here — they may not exist yet
}
connectedCallback() {
console.log('connectedCallback: element added to DOM');
// Ideal place to set up event listeners, fetch data, render content
this.innerHTML = `
<div class="lifecycle-demo">
<p>I am now connected to the document</p>
</div>
`;
}
disconnectedCallback() {
console.log('disconnectedCallback: element removed from DOM');
// Clean up event listeners, cancel requests, stop timers
window.removeEventListener('resize', this._onResize);
}
adoptedCallback() {
console.log('adoptedCallback: element moved to a new document');
// Called when element moves between documents (e.g., with iframes)
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
// React to attribute changes — re-render or update state
}
// Tell the browser which attributes to watch
static get observedAttributes() {
return ['status', 'priority'];
}
_onResize() {
// Handle resize
}
}
customElements.define('lifecycle-demo', LifecycleDemo);
Key rules to remember:
- The constructor runs once, before the element enters the DOM. Don't access children or attributes here — use
connectedCallbackfor that connectedCallbackis the workhorse. It fires every time the element is connected to a document, which could happen multiple times if the element is moved around- Always clean up in
disconnectedCallback— event listeners, intervals, and fetch requests left dangling cause memory leaks attributeChangedCallbackonly fires for attributes listed inobservedAttributes
Attributes and Properties
Custom elements shine when they expose a clear attribute-to-property contract. Attributes are the public API declared in HTML markup; properties are the programmatic interface accessed from JavaScript. Keeping them synchronized avoids confusion.
class StatusBadge extends HTMLElement {
// Observe these attributes
static get observedAttributes() {
return ['status', 'label'];
}
constructor() {
super();
this._status = 'active';
this._label = '';
}
connectedCallback() {
this._render();
}
attributeChangedCallback(name, oldValue, newValue) {
// Sync property when attribute changes
if (name === 'status') {
this._status = newValue || 'active';
} else if (name === 'label') {
this._label = newValue || '';
}
this._render();
}
// Property getters/setters keep the DOM attribute in sync
get status() {
return this._status;
}
set status(value) {
this._status = value;
// Reflect property change back to the attribute
if (value) {
this.setAttribute('status', value);
} else {
this.removeAttribute('status');
}
this._render();
}
get label() {
return this._label;
}
set label(value) {
this._label = value;
if (value) {
this.setAttribute('label', value);
} else {
this.removeAttribute('label');
}
this._render();
}
_render() {
const colors = {
active: '#2ecc71',
pending: '#f39c12',
error: '#e74c3c',
};
const color = colors[this._status] || '#95a5a6';
this.innerHTML = `
<span class="badge" style="
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
background: ${color};
color: white;
font-size: 0.85rem;
font-family: sans-serif;
">
${this._label || this._status}
</span>
`;
}
}
customElements.define('status-badge', StatusBadge);
Now you can use it from HTML or JavaScript:
<status-badge status="active" label="Deployed"></status-badge>
<status-badge status="pending" label="Building"></status-badge>
<script>
const badge = document.querySelector('status-badge');
badge.status = 'error'; // triggers re-render and updates the attribute
console.log(badge.getAttribute('status')); // 'error'
</script>
The reflection pattern — where property setters update attributes and attribute changes update properties — ensures the element's state is always consistent regardless of how consumers interact with it.
Shadow DOM Integration
Custom elements become truly powerful when paired with Shadow DOM. The shadow root provides a scoped DOM subtree with isolated styles, preventing CSS collisions and keeping internal structure hidden from external selectors.
Building a Card Component with Shadow DOM
class InfoCard extends HTMLElement {
constructor() {
super();
// Create a shadow root in "open" mode (accessible via element.shadowRoot)
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['heading', 'body-text', 'image-url'];
}
connectedCallback() {
// Build the shadow DOM structure once
this._render();
}
attributeChangedCallback(name, oldValue, newValue) {
// Re-render when any observed attribute changes
this._render();
}
_render() {
const heading = this.getAttribute('heading') || 'Untitled';
const bodyText = this.getAttribute('body-text') || '';
const imageUrl = this.getAttribute('image-url') || '';
// Styles defined here only apply inside the shadow root
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
max-width: 320px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
font-family: system-ui, sans-serif;
background: #fff;
margin: 1rem 0;
}
:host(:hover) {
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
transform: translateY(-2px);
transition: all 0.2s ease;
}
.card-image {
width: 100%;
height: 180px;
object-fit: cover;
display: ${imageUrl ? 'block' : 'none'};
}
.card-body {
padding: 1.25rem;
}
.card-heading {
margin: 0 0 0.5rem;
font-size: 1.2rem;
color: #1a1a1a;
}
.card-text {
margin: 0;
color: #555;
line-height: 1.5;
font-size: 0.95rem;
}
</style>
${imageUrl ? `
` : ''}
<div class="card-body">
<h3 class="card-heading">${heading}</h3>
<p class="card-text">${bodyText}</p>
</div>
`;
}
}
customElements.define('info-card', InfoCard);
Usage in HTML:
<info-card
heading="Mountain Sunrise"
body-text="A breathtaking view of alpine peaks at dawn, with golden light spilling over the ridges."
image-url="https://images.unsplash.com/photo-1506905921-5b4b0b0b0b0b?w=600"
></info-card>
The :host pseudo-class targets the custom element itself from inside the shadow root. Styles inside the shadow DOM never leak out, and external styles never pierce in — except for inheritable properties like font-family and color, which cascade through by design.
Slots and Composition
Shadow DOM's <slot> mechanism allows custom elements to accept and project arbitrary DOM content, creating a compositional API similar to what you'd expect from a framework component. Slots bridge the gap between the shadow boundary and the light DOM.
A Dialog Component Using Slots
class ModalDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this._render();
this._setupEvents();
}
disconnectedCallback() {
this._cleanupEvents();
}
_render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
}
:host([open]) {
display: flex;
align-items: center;
justify-content: center;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
}
.dialog {
position: relative;
background: white;
border-radius: 12px;
max-width: 480px;
width: 90%;
padding: 0;
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
}
.header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h2 {
margin: 0;
font-size: 1.15rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
color: #666;
}
.body {
padding: 1.25rem 1.5rem;
}
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>
<div class="overlay"></div>
<div class="dialog" role="dialog" aria-modal="true">
<div class="header">
<h2><slot name="title">Dialog</slot></h2>
<button class="close-btn" aria-label="Close">×</button>
</div>
<div class="body">
<slot>Default body content goes here</slot>
</div>
<div class="footer">
<slot name="actions"></slot>
</div>
</div>
`;
}
_setupEvents() {
this._closeHandler = () => this.close();
this._keyHandler = (e) => {
if (e.key === 'Escape') this.close();
};
const closeBtn = this.shadowRoot.querySelector('.close-btn');
const overlay = this.shadowRoot.querySelector('.overlay');
closeBtn.addEventListener('click', this._closeHandler);
overlay.addEventListener('click', this._closeHandler);
document.addEventListener('keydown', this._keyHandler);
}
_cleanupEvents() {
document.removeEventListener('keydown', this._keyHandler);
}
open() {
this.setAttribute('open', '');
}
close() {
this.removeAttribute('open');
}
static get observedAttributes() {
return ['open'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'open') {
// Dispatch a custom event so consumers can react
this.dispatchEvent(new CustomEvent(
newValue !== null ? 'dialog-opened' : 'dialog-closed',
{ bubbles: true, composed: true }
));
}
}
}
customElements.define('modal-dialog', ModalDialog);
Using named slots for structured composition:
<modal-dialog id="confirm-dialog">
<h2 slot="title">Confirm Deletion</h2>
<p>Are you sure you want to permanently delete this item? This action cannot be undone.</p>
<div slot="actions">
<button class="btn-secondary">Cancel</button>
<button class="btn-danger">Delete</button>
</div>
</modal-dialog>
<script>
const dialog = document.querySelector('#confirm-dialog');
dialog.addEventListener('dialog-opened', () => console.log('Dialog opened'));
dialog.addEventListener('dialog-closed', () => console.log('Dialog closed'));
dialog.open(); // Programmatically open
</script>
Slots with name attributes match content by slot attribute. The unnamed <slot> acts as a catch-all for any light DOM content not assigned to a named slot. This pattern lets consumers compose rich, semantic HTML inside your component.
Real-World Example: A Tab Container
Let's build a complete, production-style custom element that ties together attributes, shadow DOM, slots, and lifecycle management into a cohesive tab interface.
class TabContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._tabs = [];
this._panels = [];
this._activeIndex = 0;
}
static get observedAttributes() {
return ['active-tab'];
}
connectedCallback() {
// Parse light DOM for tabs and panels
this._parseStructure();
this._render();
this._addEventListeners();
}
disconnectedCallback() {
this._removeEventListeners();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'active-tab' && oldValue !== newValue) {
const index = parseInt(newValue, 10);
if (!isNaN(index) && index >= 0 && index < this._tabs.length) {
this._activateTab(index);
}
}
}
_parseStructure() {
// Children with slot="tab" become tabs
// Children with slot="panel" become panels
const tabElements = this.querySelectorAll('[slot="tab"]');
const panelElements = this.querySelectorAll('[slot="panel"]');
this._tabs = Array.from(tabElements).map(el => ({
label: el.textContent.trim(),
disabled: el.hasAttribute('disabled'),
}));
this._panels = Array.from(panelElements).map(el => ({
content: el.innerHTML,
}));
}
_render() {
const tabButtons = this._tabs.map((tab, i) => {
const isActive = i === this._activeIndex;
const disabledAttr = tab.disabled ? 'disabled' : '';
return `
<button
class="tab-btn ${isActive ? 'active' : ''}"
role="tab"
aria-selected="${isActive}"
data-index="${i}"
${disabledAttr}
>${tab.label}</button>
`;
}).join('');
const panels = this._panels.map((panel, i) => {
const isActive = i === this._activeIndex;
return `
<div
class="panel ${isActive ? 'active' : ''}"
role="tabpanel"
aria-hidden="${!isActive}"
>${panel.content}</div>
`;
}).join('');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui, sans-serif;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.tab-list {
display: flex;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
padding: 0;
}
.tab-btn {
padding: 0.75rem 1.25rem;
border: none;
background: none;
cursor: pointer;
font-size: 0.95rem;
color: #555;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
outline: none;
}
.tab-btn:hover:not(:disabled) {
color: #1a1a1a;
background: #ebebeb;
}
.tab-btn.active {
color: #4a90d9;
border-bottom-color: #4a90d9;
font-weight: 600;
}
.tab-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.panel {
display: none;
padding: 1.5rem;
line-height: 1.6;
color: #333;
}
.panel.active {
display: block;
}
</style>
<div class="tab-list" role="tablist">${tabButtons}</div>
<div class="panels-container">${panels}</div>
`;
}
_activateTab(index) {
if (index < 0 || index >= this._tabs.length) return;
if (this._tabs[index].disabled) return;
this._activeIndex = index;
this.setAttribute('active-tab', String(index));
// Update button states
const buttons = this.shadowRoot.querySelectorAll('.tab-btn');
buttons.forEach((btn, i) => {
btn.classList.toggle('active', i === index);
btn.setAttribute('aria-selected', String(i === index));
});
// Update panel visibility
const panels = this.shadowRoot.querySelectorAll('.panel');
panels.forEach((panel, i) => {
panel.classList.toggle('active', i === index);
panel.setAttribute('aria-hidden', String(i !== index));
});
// Dispatch event for consumers
this.dispatchEvent(new CustomEvent('tab-change', {
detail: { index, label: this._tabs[index].label },
bubbles: true,
composed: true,
}));
}
_addEventListeners() {
this._clickHandler = (e) => {
const btn = e.target.closest('.tab-btn');
if (!btn) return;
const index = parseInt(btn.dataset.index, 10);
this._activateTab(index);
};
this.shadowRoot.addEventListener('click', this._clickHandler);
}
_removeEventListeners() {
this.shadowRoot.removeEventListener('click', this._clickHandler);
}
// Public API
selectTab(index) {
this._activateTab(index);
}
get activeTab() {
return this._activeIndex;
}
}
customElements.define('tab-container', TabContainer);
Usage with light DOM composition:
<tab-container>
<span slot="tab">Overview</span>
<span slot="tab">Features</span>
<span slot="tab" disabled>Admin (coming soon)</span>
<div slot="panel">
<h3>Project Overview</h3>
<p>This dashboard shows key metrics and recent activity across all your projects.</p>
</div>
<div slot="panel">
<h3>Key Features</h3>
<ul>
<li>Real-time collaboration</li>
<li>Automated workflows</li>
<li>Custom reporting</li>
</ul>
</div>
<div slot="panel">
<p>Admin panel access requires elevated permissions.</p>
</div>
</tab-container>
<script>
const tabs = document.querySelector('tab-container');
tabs.addEventListener('tab-change', (e) => {
console.log(`Switched to tab: ${e.detail.label} (index ${e.detail.index})`);
});
// Programmatic control
setTimeout(() => tabs.selectTab(1), 2000);
</script>
Best Practices
Building custom elements that are robust, accessible, and maintainable requires more than just wiring up a class. Follow these guidelines to avoid common pitfalls:
- Always use a hyphen in the tag name. The spec requires at least one hyphen to distinguish custom elements from built-in ones. Use a prefix like your organization name:
<acme-button>,<my-org-data-grid> - Keep constructors minimal. Don't inspect children, attributes, or the DOM in the constructor. The element may be created via
document.createElement()before being connected. Defer setup toconnectedCallback - Clean up resources in
disconnectedCallback. Remove event listeners, cancel animation frames, clear timers, and abort fetch controllers. A disconnected element that leaks resources is a silent performance drain - Use
observedAttributessparingly. Only observe attributes that directly affect rendering or state. Each observed attribute adds overhead tosetAttributecalls - Reflect properties to attributes for primitive values. This keeps the DOM representation consistent and makes the element debuggable via the browser inspector. Use
setAttributein property setters for strings, numbers, and booleans - Dispatch custom events for significant state changes. Use
bubbles: trueandcomposed: trueso events traverse shadow boundaries and can be caught by parent components - Prefer Shadow DOM for complex internals. If your element has more than a few DOM nodes or any styling, use Shadow DOM to encapsulate it. For simple text-only elements, light DOM rendering is fine
- Make ARIA a first-class concern. Set appropriate roles, states, and properties. Use
aria-selected,aria-expanded,aria-hidden, and manage focus when needed. Custom elements without ARIA are invisible to assistive technology - Test with multiple frameworks. Verify your element works when instantiated in React (which uses property assignment), Vue (which uses attributes), and plain HTML. The reflection pattern handles most discrepancies
- Keep elements single-purpose. A custom element should do one thing well. Compose complex interfaces by nesting simpler elements rather than building monolithic components
Testing Custom Elements
Testing custom elements is straightforward because they're just DOM nodes. Use any testing library that can manipulate the DOM — jsdom, Testing Library, or even plain assertions in a browser environment.
// Example test using jsdom and vitest
import { describe, it, expect } from 'vitest';
describe('status-badge', () => {
it('renders with default status', () => {
document.body.innerHTML = '<status-badge></status-badge>';
const badge = document.querySelector('status-badge');
// Wait for connectedCallback
return new Promise(resolve => {
setTimeout(() => {
expect(badge.querySelector('.badge')).toBeTruthy();
expect(badge.querySelector('.badge').textContent).toContain('active');
resolve();
}, 0);
});
});
it('reflects status property to attribute', () => {
const badge = document.createElement('status-badge');
document.body.appendChild(badge);
badge.status = 'error';
expect(badge.getAttribute('status')).toBe('error');
badge.remove();
});
it('dispatches tab-change event', () => {
document.body.innerHTML = `
<tab-container>
<span slot="tab">First</span>
<span slot="tab">Second</span>
<div slot="panel">Content 1</div>
<div slot="panel">Content 2</div>
</tab-container>
`;
const container = document.querySelector('tab-container');
let eventDetail = null;
container.addEventListener('tab-change', (e) => {
eventDetail = e.detail;
});
container.selectTab(1);
expect(eventDetail.index).toBe(1);
expect(eventDetail.label).toBe('Second');
});
});
Conclusion
HTML custom elements represent a fundamental shift in how we think about UI composition on the web. They bring the component model directly into the browser, untethered from any particular framework or build pipeline. By mastering the lifecycle callbacks, attribute reflection, Shadow DOM encapsulation, and slot-based composition, you gain the ability to build truly portable, future-proof interface primitives. The patterns shown here — from simple text banners to full tab containers with ARIA support — form the foundation for a library of reusable elements that will work identically across projects, teams, and years. Start small, encapsulate thoughtfully, and let the web platform carry your components forward.