What Are HTML Custom Elements?
HTML Custom Elements are a core part of the Web Components specification that allow developers to define their own fully-featured HTML elements. Instead of being limited to standard elements like <div>, <button>, or <input>, you can create custom tags such as <user-profile>, <star-rating>, or <animated-carousel> that encapsulate specific behavior, markup, and styling.
Custom Elements are powered by the customElements API, which lives on the global window object. They come in two flavors:
- Autonomous Custom Elements β completely new elements that extend
HTMLElement(or a more specific interface likeHTMLDivElement) and are used with their own unique tag names. - Customized Built-In Elements β extensions of existing HTML elements (like
<button>or<input>) that inherit all the native behavior and can be used with anisattribute.
When you register a custom element, the browser treats it just like any built-in element. It participates in the regular DOM tree, responds to JavaScript queries, and can be styled with CSS. The real magic, however, is that each custom element instance has its own JavaScript class backing it, complete with lifecycle hooks, encapsulated state, and optional Shadow DOM for style isolation.
Why Custom Elements Matter
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before Custom Elements, component-based architectures were only possible through JavaScript frameworks like React, Vue, or Angular. Custom Elements bring componentization directly to the platform. Here's why that matters:
- Framework Agnostic β A custom element works everywhere: React, Vue, plain HTML, server-rendered pages, or even inside Markdown files. You write it once and reuse it across any stack.
- Native Browser Support β No polyfills or runtime libraries required in modern browsers. The element is parsed, rendered, and garbage-collected natively.
- Encapsulation β Combined with Shadow DOM, custom elements isolate their internal DOM and CSS from the rest of the page, preventing accidental style leaks or selector collisions.
- Interoperability β Custom elements expose a standard DOM interface: attributes, properties, events, and methods. Any developer can inspect and interact with them using familiar browser APIs.
- Longevity β Because they're built on web standards, custom elements outlast framework trends. Code written today will still work in browsers ten years from now.
- Progressive Enhancement β Custom elements can enhance server-rendered HTML. The page loads with meaningful content, and the custom element upgrades it with interactivity when JavaScript loads.
Creating Your First Autonomous Custom Element
Let's build a simple <greeting-badge> element from scratch. This element accepts a name attribute and displays a styled greeting message.
Step 1: Define the Class
Every custom element must be backed by a JavaScript class that extends HTMLElement:
class GreetingBadge extends HTMLElement {
constructor() {
super(); // You must call super() first
this._name = 'World';
}
}
Step 2: Add Lifecycle Methods
The browser calls specific methods at key moments in an element's life. The most important ones are connectedCallback() and attributeChangedCallback():
class GreetingBadge extends HTMLElement {
constructor() {
super();
this._name = 'World';
}
connectedCallback() {
// Called when the element is added to the DOM
this.render();
}
attributeChangedCallback(name, oldValue, newValue) {
// Called when an observed attribute changes
if (name === 'name') {
this._name = newValue || 'World';
this.render();
}
}
// Tell the browser which attributes to watch
static get observedAttributes() {
return ['name'];
}
render() {
this.textContent = `Hello, ${this._name}! π`;
}
}
Step 3: Register the Element
After defining the class, you must register it with a tag name. Tag names for autonomous custom elements must contain a hyphen (e.g., my-element, user-card). This prevents collisions with future standard HTML elements.
customElements.define('greeting-badge', GreetingBadge);
Step 4: Use It in HTML
Once registered, you can use the element like any standard HTML tag:
<greeting-badge name="Alice"></greeting-badge>
<greeting-badge name="Bob"></greeting-badge>
<greeting-badge></greeting-badge> <!-- Falls back to "World" -->
Full working example:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Element Demo</title>
</head>
<body>
<greeting-badge name="Alice"></greeting-badge>
<greeting-badge name="Bob"></greeting-badge>
<greeting-badge></greeting-badge>
<script>
class GreetingBadge extends HTMLElement {
constructor() {
super();
this._name = 'World';
}
connectedCallback() {
this.render();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'name') {
this._name = newValue || 'World';
this.render();
}
}
static get observedAttributes() {
return ['name'];
}
render() {
this.textContent = `Hello, ${this._name}! π`;
}
}
customElements.define('greeting-badge', GreetingBadge);
</script>
</body>
</html>
Customized Built-In Elements
Sometimes you want to extend an existing HTML element rather than create a brand-new one. For example, you might want a <button> that automatically shows a loading spinner when clicked. Customized built-in elements extend a specific HTML interface and are used with the is attribute.
To create one, extend the corresponding class (e.g., HTMLButtonElement) and register it with a third argument specifying the base element:
class SpinnerButton extends HTMLButtonElement {
constructor() {
super();
this._listening = false;
}
connectedCallback() {
if (!this._listening) {
this.addEventListener('click', this._handleClick.bind(this));
this._listening = true;
}
}
_handleClick(e) {
const originalText = this.textContent;
this.disabled = true;
this.textContent = 'Loadingβ¦';
// Simulate async operation
setTimeout(() => {
this.disabled = false;
this.textContent = originalText;
}, 2000);
}
}
customElements.define('spinner-button', SpinnerButton, { extends: 'button' });
Usage in HTML:
<button is="spinner-button">Click Me</button>
Important note: Customized built-in elements have limited support in some frameworks. Safari historically had issues with the is syntax, though this has improved. Many developers prefer autonomous custom elements for cross-browser consistency. For maximum compatibility, consider wrapping a standard button inside your autonomous custom element's Shadow DOM instead.
The Full Lifecycle Callbacks
Custom elements have four key lifecycle methods. Understanding their order and purpose is essential for building reliable components:
constructor()β Called when the element class is instantiated. Use this for basic initialization, setting default property values, and attaching internal state. Never access attributes in the constructor (they may not be available yet) and never manipulate DOM children here. Keep it light.connectedCallback()β Invoked each time the element is added to the document DOM. This is where you set up event listeners, fetch data, render content, or start animations. It may be called multiple times if the element is moved around the DOM.disconnectedCallback()β Called when the element is removed from the document DOM. Use this to clean up event listeners, cancel requests, stop timers, or release resources to prevent memory leaks.attributeChangedCallback(name, oldValue, newValue)β Called when an observed attribute's value changes. Only attributes listed in the staticobservedAttributesgetter trigger this callback. Use it to react to external changes and update the component state.
Here's a complete element demonstrating all four callbacks:
class TimerDisplay extends HTMLElement {
constructor() {
super();
this._seconds = 0;
this._intervalId = null;
console.log('constructor called');
}
static get observedAttributes() {
return ['running'];
}
connectedCallback() {
console.log('connectedCallback: element added to DOM');
this.render();
if (this.hasAttribute('running')) {
this._startTimer();
}
}
disconnectedCallback() {
console.log('disconnectedCallback: element removed from DOM');
this._stopTimer();
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`attributeChangedCallback: ${name} changed from ${oldValue} to ${newValue}`);
if (name === 'running') {
if (newValue !== null) {
this._startTimer();
} else {
this._stopTimer();
}
}
}
_startTimer() {
if (this._intervalId) return; // Already running
this._intervalId = setInterval(() => {
this._seconds++;
this.render();
}, 1000);
}
_stopTimer() {
clearInterval(this._intervalId);
this._intervalId = null;
}
render() {
this.textContent = `Seconds elapsed: ${this._seconds}`;
}
}
customElements.define('timer-display', TimerDisplay);
HTML usage:
<timer-display running></timer-display>
Working with Attributes and Properties
A well-designed custom element exposes its state through both HTML attributes and JavaScript properties, keeping them synchronized. This is sometimes called "reflecting" properties to attributes.
Here's a toggle-switch element that demonstrates the pattern:
class ToggleSwitch extends HTMLElement {
constructor() {
super();
this._checked = false;
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['checked', 'disabled', 'label'];
}
// Property getter/setter with attribute reflection
get checked() {
return this._checked;
}
set checked(value) {
this._checked = Boolean(value);
if (this._checked) {
this.setAttribute('checked', '');
} else {
this.removeAttribute('checked');
}
this._updateDisplay();
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
connectedCallback() {
this._checked = this.hasAttribute('checked');
this._render();
this.shadowRoot.querySelector('.toggle').addEventListener('click', () => {
if (!this.disabled) {
this.checked = !this.checked;
this.dispatchEvent(new CustomEvent('change', {
detail: { checked: this.checked },
bubbles: true,
composed: true
}));
}
});
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'checked') {
this._checked = newValue !== null;
this._updateDisplay();
} else if (name === 'disabled') {
this._updateDisplay();
} else if (name === 'label') {
this._updateLabel();
}
}
_render() {
this.shadowRoot.innerHTML = `
<style>
.toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.switch {
width: 50px;
height: 28px;
border-radius: 14px;
background: #ccc;
position: relative;
transition: background 0.3s;
margin-right: 8px;
}
.switch.checked {
background: #4cd964;
}
.switch.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.knob {
width: 24px;
height: 24px;
border-radius: 12px;
background: white;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.switch.checked .knob {
left: 24px;
}
.label {
font-family: sans-serif;
font-size: 14px;
}
</style>
<div class="toggle">
<div class="switch" id="switch">
<div class="knob"></div>
</div>
<span class="label"></span>
</div>
`;
this._updateDisplay();
this._updateLabel();
}
_updateDisplay() {
const switchEl = this.shadowRoot?.getElementById('switch');
if (!switchEl) return;
if (this._checked) {
switchEl.classList.add('checked');
} else {
switchEl.classList.remove('checked');
}
if (this.disabled) {
switchEl.classList.add('disabled');
} else {
switchEl.classList.remove('disabled');
}
}
_updateLabel() {
const labelEl = this.shadowRoot?.querySelector('.label');
if (labelEl) {
labelEl.textContent = this.getAttribute('label') || '';
}
}
}
customElements.define('toggle-switch', ToggleSwitch);
Now you can use it with attributes or properties:
<!-- HTML attribute -->
<toggle-switch checked label="Notifications"></toggle-switch>
<script>
// JavaScript property
const toggle = document.querySelector('toggle-switch');
console.log(toggle.checked); // true
toggle.checked = false; // Updates both property and attribute
// Listen for changes
toggle.addEventListener('change', (e) => {
console.log('Toggle changed:', e.detail.checked);
});
</script>
Shadow DOM and Style Encapsulation
Custom elements truly shine when combined with Shadow DOM. By calling this.attachShadow({ mode: 'open' }) in the constructor, you create a separate DOM subtree that is isolated from the main document. Styles defined inside the Shadow DOM don't leak out, and external styles don't penetrate in (except for inheritable properties like font-family and color).
Here's a complete <user-card> component with Shadow DOM encapsulation:
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._userData = null;
}
static get observedAttributes() {
return ['user-id'];
}
connectedCallback() {
const userId = this.getAttribute('user-id');
if (userId) {
this.fetchUser(userId);
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'user-id' && newValue && newValue !== oldValue) {
this.fetchUser(newValue);
}
}
async fetchUser(userId) {
// Simulated API call β replace with real fetch in production
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
font-family: sans-serif;
max-width: 300px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #7c3aed;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 12px;
}
.name {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
}
.email {
color: #666;
font-size: 14px;
margin: 0 0 8px 0;
}
.bio {
font-size: 14px;
line-height: 1.4;
color: #333;
}
.skeleton {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
}
.skeleton-name {
width: 120px;
height: 20px;
margin-bottom: 8px;
}
.skeleton-email {
width: 180px;
height: 16px;
margin-bottom: 8px;
}
.skeleton-bio {
width: 100%;
height: 40px;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
</style>
<div class="skeleton-avatar skeleton"></div>
<div class="skeleton-name skeleton"></div>
<div class="skeleton-email skeleton"></div>
<div class="skeleton-bio skeleton"></div>
`;
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 1000));
// In a real app, you'd do: const response = await fetch(`/api/users/${userId}`);
const mockUser = {
name: 'Jane Doe',
email: 'jane@example.com',
bio: 'Software engineer and open source enthusiast. Loves building web components.'
};
this._userData = mockUser;
this.renderUser();
}
renderUser() {
const user = this._userData;
const initials = user.name.split(' ').map(n => n[0]).join('');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
font-family: sans-serif;
max-width: 300px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.2s;
}
:host(:hover) {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #7c3aed;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 12px;
}
.name {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
}
.email {
color: #666;
font-size: 14px;
margin: 0 0 8px 0;
}
.bio {
font-size: 14px;
line-height: 1.4;
color: #333;
}
</style>
<div class="avatar">${initials}</div>
<h2 class="name">${user.name}</h2>
<p class="email">${user.email}</p>
<p class="bio">${user.bio}</p>
`;
}
}
customElements.define('user-card', UserCard);
Use it simply as:
<user-card user-id="42"></user-card>
The Shadow DOM's :host selector lets you style the custom element itself from within its shadow tree. :host(:hover) applies styles when the user hovers over the element. The mode: 'open' option makes the shadow root accessible via element.shadowRoot for debugging; use mode: 'closed' if you want to prevent external access (though this is rarely necessary).
Dispatching Events from Custom Elements
Custom elements communicate with the outside world by dispatching DOM events. Always use bubbles: true and composed: true so events cross the Shadow DOM boundary and bubble up through the document tree:
class StarRating extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._value = 0;
}
static get observedAttributes() {
return ['value', 'max'];
}
connectedCallback() {
this._value = parseInt(this.getAttribute('value') || '0', 10);
const max = parseInt(this.getAttribute('max') || '5', 10);
this.render(max);
}
render(max) {
let starsHtml = '';
for (let i = 1; i <= max; i++) {
const filled = i <= this._value ? 'filled' : '';
starsHtml += `<span class="star ${filled}" data-index="${i}">β
</span>`;
}
this.shadowRoot.innerHTML = `
<style>
.stars { display: inline-flex; gap: 2px; }
.star {
font-size: 24px;
color: #ccc;
cursor: pointer;
transition: color 0.2s, transform 0.1s;
user-select: none;
}
.star.filled { color: #f5a623; }
.star:hover { transform: scale(1.2); }
</style>
<div class="stars">${starsHtml}</div>
`;
this.shadowRoot.querySelectorAll('.star').forEach(star => {
star.addEventListener('click', (e) => {
const newValue = parseInt(e.target.dataset.index, 10);
this.setAttribute('value', newValue);
this._value = newValue;
this.render(max);
this.dispatchEvent(new CustomEvent('rating-change', {
detail: { value: newValue },
bubbles: true,
composed: true
}));
});
});
}
}
customElements.define('star-rating', StarRating);
Consumers can listen for the event like any other DOM event:
<star-rating value="3" max="5"></star-rating>
<script>
document.querySelector('star-rating').addEventListener('rating-change', (e) => {
console.log(`User rated: ${e.detail.value} stars`);
// Send to analytics, update UI, etc.
});
</script>
Advanced Example: A Complete Tabs Component
Let's build a fully functional <tab-container> component that demonstrates slots, multiple custom elements working together, and complex state management. This example combines two custom elements: <tab-container> and <tab-panel>.
// tab-panel.js
class TabPanel extends HTMLElement {
static get observedAttributes() {
return ['active'];
}
connectedCallback() {
this._updateVisibility();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'active') {
this._updateVisibility();
}
}
_updateVisibility() {
if (this.hasAttribute('active')) {
this.style.display = 'block';
} else {
this.style.display = 'none';
}
}
}
// tab-container.js
class TabContainer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._tabs = [];
this._panels = [];
}
connectedCallback() {
// Collect slotted tab headers and panels
this._tabs = Array.from(this.querySelectorAll('[slot="tab"]'));
this._panels = Array.from(this.querySelectorAll('tab-panel'));
this.render();
this._activateTab(0);
}
render() {
const tabLabels = this._tabs.map((tab, i) => `
<button class="tab-btn" data-index="${i}">
${tab.textContent}
</button>
`).join('');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
font-family: sans-serif;
}
.tab-header {
display: flex;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.tab-btn {
flex: 1;
padding: 12px 16px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
transition: background 0.2s, color 0.2s;
border-bottom: 2px solid transparent;
}
.tab-btn:hover {
background: #e8e8e8;
color: #333;
}
.tab-btn.active {
color: #7c3aed;
border-bottom-color: #7c3aed;
background: white;
}
.panel-container {
padding: 16px;
background: white;
}
::slotted([slot="tab"]) {
display: none;
}
</style>
<div class="tab-header">${tabLabels}</div>
<div class="panel-container">
<slot name="panel"></slot>
</div>
`;
this.shadowRoot.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = parseInt(e.target.dataset.index, 10);
this._activateTab(index);
});
});
}
_activateTab(index) {
// Update button states
const buttons = this.shadowRoot.querySelectorAll('.tab-btn');
buttons.forEach((btn, i) => {
btn.classList.toggle('active', i === index);
});
// Update panel visibility
this._panels.forEach((panel, i) => {
if (i === index) {
panel.setAttribute('active', '');
} else {
panel.removeAttribute('active');
}
});
this.dispatchEvent(new CustomEvent('tab-change', {
detail: { index, tab: this._tabs[index]?.textContent || '' },
bubbles: true,
composed: true
}));
}
}
customElements.define('tab-panel', TabPanel);
customElements.define('tab-container', TabContainer);
HTML usage with slots:
<tab-container>
<span slot="tab">Home</span>
<span slot="tab">Profile</span>
<span slot="tab">Settings</span>
<tab-panel slot="panel">
<h3>Welcome Home</h3>
<p>This is the home tab content.</p>
</tab-panel>
<tab-panel slot="panel">
<h3>Your Profile</h3>
<p>Edit your profile information here.</p>
</tab-panel>
<tab-panel slot="panel">
<h3>Settings</h3>
<p>Configure application settings.</p>
</tab-panel>
</tab-container>
This pattern demonstrates how multiple custom elements can collaborate. The <tab-container> orchestrates the tabs, while each <tab-panel> manages its own visibility. Slots provide a clean way to define tab labels and panel content directly in HTML.
Best Practices for Custom Elements
After building many custom elements, certain patterns emerge that lead to robust, maintainable components. Here are the most important best practices:
- Always call super() first β In the constructor,
super()must be the very first statement. The element isn't fully initialized until the parent class constructor runs. - Keep constructors minimal β Don't access attributes, children, or the DOM in the constructor. Defer DOM manipulation to
connectedCallback. The element may be created before being connected to a document. - Use observedAttributes for reactive updates β Always define the static
observedAttributesgetter when you need to react to attribute changes. Without it,attributeChangedCallbackwill never fire. - Reflect properties to attributes β For important state, keep properties and attributes in sync. This allows both
el.checked = true(JavaScript) and<my-el checked>(HTML) to work correctly. - Use Shadow DOM for style isolation β When your component has internal DOM structure that shouldn't be affected by global CSS, use Shadow DOM. For simple text-only elements, you can skip it.
- Dispatch events with bubbles and composed β Set
bubbles: trueandcomposed: trueon custom events so they traverse Shadow DOM boundaries and can be caught by parent elements. - Clean up in disconnectedCallback β Remove event listeners, cancel fetch requests, clear intervals, and release observers when the element leaves the DOM. This prevents memory leaks in long-lived applications.
- Use meaningful tag names with hyphens β Names like
<user-profile>,<data-table>, or<search-bar>are self-documenting. The hyphen requirement also guarantees forward compatibility with the HTML spec. - Make components self-contained β Each custom element should be a complete, reusable unit. Avoid dependencies on specific parent structures or global state. Pass configuration via attributes and properties.
- Test progressively β Verify your element works when added dynamically with
document.createElement, when present in initial HTML, and when moved between documents. Test attribute changes before and after connection. - Provide fallback content with slots β Use
<slot>elements to allow consumers to provide their own content. Unslotted default content serves as a graceful fallback. - Document attributes, events, and slots β Treat your custom element like a public API. Clearly document which attributes it accepts, what events it fires, and what slots are available for customization.
Browser Support and Polyfills
Custom Elements v1 (the current specification) is supported in all modern browsers: Chrome, Firefox, Safari, and Edge. Internet Explorer does not support Custom Elements, but for projects that require legacy