Skip to main content

How to Create Custom HTML Elements in JavaScript

Introduction

Every HTML element you use daily, from <button> to <video>, was defined by browser vendors following the HTML specification. For years, developers had no way to create their own first-class HTML elements. The best you could do was slap a class on a <div> and wire up behavior with JavaScript. The result was an endless sea of <div class="card">, <div class="modal">, <div class="tabs">, none of which carried any semantic meaning or encapsulated behavior.

Custom Elements change that entirely. They are a browser-native API that lets you define your own HTML tags with custom behavior, attributes, and lifecycle hooks. Once defined, a custom element like <user-card> or <data-table> is treated by the browser just like a built-in element. It can be created with document.createElement(), targeted with querySelector(), manipulated with attributes, and used declaratively in HTML.

Custom Elements are the most fundamental pillar of Web Components. In this guide, you will learn how to create autonomous custom elements from scratch, register them with customElements.define(), respond to lifecycle events, observe attribute changes, extend built-in HTML elements, and understand the element upgrade process.

Autonomous Custom Elements

An autonomous custom element is a completely new HTML tag that does not inherit behavior from any existing element. It extends the base HTMLElement class and defines its own rendering, behavior, and API from the ground up.

The Basic Structure

Every custom element is a JavaScript class that extends HTMLElement:

class MyGreeting extends HTMLElement {
constructor() {
super(); // Always call super() first
// Initialization logic here
}
}

// Register the element
customElements.define('my-greeting', MyGreeting);

Now <my-greeting> is a valid HTML tag:

<my-greeting></my-greeting>

You can also create it programmatically:

const greeting = document.createElement('my-greeting');
document.body.appendChild(greeting);

Naming Rules

Custom element names have strict rules enforced by the browser:

  • Must contain a hyphen (-): This is the single most important rule. Names like user-card, app-header, x-button are valid. Names like usercard, mycomponent, or header are not valid.
  • Must start with a lowercase letter: My-Element is invalid. my-element is valid.
  • Cannot be a single word: The hyphen requirement inherently prevents this.
  • Cannot match reserved names: A few names are explicitly forbidden, like annotation-xml, color-profile, font-face, font-face-src, font-face-uri, font-face-format, font-face-name, missing-glyph.

The hyphen requirement exists for a critical reason: it guarantees that custom element names will never collide with current or future standard HTML elements (which never contain hyphens).

// Valid names
customElements.define('user-card', UserCard);
customElements.define('app-sidebar', AppSidebar);
customElements.define('x-button', XButton);
customElements.define('my-super-long-element-name', MyComponent);

// INVALID: will throw errors
customElements.define('usercard', UserCard); // No hyphen
customElements.define('UserCard', UserCard); // Uppercase
customElements.define('123-card', UserCard); // Starts with number

A Practical Autonomous Custom Element

Here is a complete, functional custom element that renders a user profile card:

class UserCard extends HTMLElement {
constructor() {
super();

// Attach Shadow DOM for encapsulation
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
const name = this.getAttribute('name') || 'Anonymous';
const role = this.getAttribute('role') || 'User';
const avatar = this.getAttribute('avatar') || '';

this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
font-family: system-ui, sans-serif;
}
.card {
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
text-align: center;
width: 200px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #3498db;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: bold;
margin: 0 auto 12px;
}
.name {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
}
.role {
font-size: 14px;
color: #888;
margin: 0;
}
</style>
<div class="card">
<div class="avatar">${avatar || name.charAt(0).toUpperCase()}</div>
<p class="name">${name}</p>
<p class="role">${role}</p>
</div>
`;
}
}

customElements.define('user-card', UserCard);
<user-card name="Alice Johnson" role="Software Engineer"></user-card>
<user-card name="Bob Smith" role="Designer" avatar="🎨"></user-card>
<user-card></user-card>

The third <user-card> uses default values ("Anonymous" and "User") since no attributes were provided.

customElements.define(name, class)

The customElements.define() method is how you register a custom element class with the browser. Until you call this method, the browser treats your custom tag as an unknown element (functionally similar to a <span>).

Syntax

customElements.define(name, constructor);
customElements.define(name, constructor, options);
ParameterDescription
nameThe tag name (string, must contain a hyphen)
constructorThe class that extends HTMLElement (or a built-in element)
optionsOptional object with { extends: 'tagname' } for customized built-in elements

Basic Registration

class NotificationBadge extends HTMLElement {
connectedCallback() {
const count = this.getAttribute('count') || '0';
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
span {
background: #e74c3c;
color: white;
border-radius: 50%;
padding: 2px 8px;
font-size: 12px;
font-weight: bold;
font-family: system-ui;
}
</style>
<span>${count}</span>
`;
}
}

customElements.define('notification-badge', NotificationBadge);

Each Name Can Only Be Registered Once

Attempting to register the same name twice throws a DOMException:

customElements.define('my-element', ClassA);
customElements.define('my-element', ClassB); // Error!
// DOMException: "my-element" has already been defined as a custom element

Similarly, a class can only be used for one element name:

customElements.define('element-a', MyClass);
customElements.define('element-b', MyClass); // Error!
// DOMException: This constructor has already been used with another element definition

The customElements Registry Methods

The customElements object (which is a CustomElementRegistry instance) provides several useful methods beyond define:

// Check if an element is defined
const constructor = customElements.get('user-card');
if (constructor) {
console.log('user-card is defined:', constructor.name);
} else {
console.log('user-card is not yet defined');
}

// Get the name for a constructor
const name = customElements.getName(UserCard);
console.log(name); // "user-card"

// Wait for an element to be defined (returns a Promise)
customElements.whenDefined('user-card').then(() => {
console.log('<user-card> is now ready to use');
});

// With async/await
await customElements.whenDefined('user-card');
console.log('<user-card> is ready');

Registration Timing

You can register a custom element before or after instances appear in the DOM. If instances exist before registration, they get "upgraded" when the definition is registered (more on this in the Upgrading Elements section).

<!-- These elements exist BEFORE the definition is loaded -->
<my-counter value="5"></my-counter>
<my-counter value="10"></my-counter>

<!-- Definition loaded later -->
<script src="my-counter.js" defer></script>

The elements initially behave like unknown elements (no special rendering or behavior). Once my-counter.js runs and calls customElements.define('my-counter', ...), the browser upgrades all existing <my-counter> elements, calling their constructor and connectedCallback.

tip

Use the CSS :defined pseudo-class to style elements differently before and after they are upgraded:

my-counter:not(:defined) {
/* Loading state: hide or show a placeholder */
visibility: hidden;
}

my-counter:defined {
/* Fully loaded and functional */
visibility: visible;
}

This prevents a flash of unstyled or broken content while the element's JavaScript is loading.

Lifecycle Callbacks

Custom elements have four lifecycle callbacks that the browser calls at specific points during the element's existence. These are the hooks that make custom elements dynamic and reactive.

connectedCallback()

Called every time the element is inserted into the DOM. This is the most commonly used callback and the recommended place for initial rendering, setting up event listeners, and fetching data.

class LiveClock extends HTMLElement {
connectedCallback() {
console.log('LiveClock added to the DOM');

this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
span {
font-family: monospace;
font-size: 24px;
background: #1a1a2e;
color: #0f0;
padding: 8px 16px;
border-radius: 6px;
}
</style>
<span></span>
`;

this.updateTime();
this.intervalId = setInterval(() => this.updateTime(), 1000);
}

updateTime() {
const now = new Date().toLocaleTimeString();
this.shadowRoot.querySelector('span').textContent = now;
}

disconnectedCallback() {
clearInterval(this.intervalId);
}
}

customElements.define('live-clock', LiveClock);
<live-clock></live-clock>

Output (updates every second):

10:30:45 AM

Important details about connectedCallback:

  • It is called every time the element is added to the DOM, not just the first time. If you remove and re-add an element, connectedCallback fires again.
  • It is not called if the element is created but never added to the DOM (document.createElement('live-clock') alone does not trigger it).
  • The element might not have children yet when connectedCallback fires if the browser is still parsing HTML. Child access can be deferred with a microtask if needed.

disconnectedCallback()

Called when the element is removed from the DOM. Use it to clean up resources: clear intervals and timeouts, remove global event listeners, close WebSocket connections, abort fetch requests, and release any external references.

class DataStream extends HTMLElement {
connectedCallback() {
console.log('DataStream connected');

this.abortController = new AbortController();

// Set up a global event listener
window.addEventListener('resize', this.handleResize, {
signal: this.abortController.signal
});

// Start polling
this.intervalId = setInterval(() => this.fetchData(), 5000);
}

disconnectedCallback() {
console.log('DataStream disconnected - cleaning up');

// Cancel the interval
clearInterval(this.intervalId);

// Remove all listeners registered with this AbortController
this.abortController.abort();
}

handleResize = () => {
console.log('Window resized');
}

fetchData() {
console.log('Fetching data...');
}
}

customElements.define('data-stream', DataStream);
// Add the element
const stream = document.createElement('data-stream');
document.body.appendChild(stream);
// Console: "DataStream connected"

// Later, remove it
stream.remove();
// Console: "DataStream disconnected - cleaning up"
// Interval is cleared, resize listener is removed
caution

disconnectedCallback is not guaranteed to fire in all scenarios. If the user closes the browser tab or navigates away, the callback may not run. Do not rely on it for critical operations like saving unsaved data. For that, use the beforeunload event or periodic auto-saving.

attributeChangedCallback(name, oldValue, newValue)

Called when one of the element's observed attributes is added, changed, or removed. This callback enables your element to react to attribute changes, similar to how a native <input> element changes its behavior when you modify its type attribute.

class ColorBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.box {
width: 100px;
height: 100px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: monospace;
font-size: 14px;
color: white;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
transition: background-color 0.3s;
}
</style>
<div class="box"></div>
`;
}

// REQUIRED: Tell the browser which attributes to watch
static get observedAttributes() {
return ['color'];
}

attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute "${name}" changed: "${oldValue}" → "${newValue}"`);

if (name === 'color') {
const box = this.shadowRoot.querySelector('.box');
box.style.backgroundColor = newValue || '#ccc';
box.textContent = newValue || 'none';
}
}
}

customElements.define('color-box', ColorBox);
<color-box color="#3498db"></color-box>
const box = document.querySelector('color-box');

// Change the attribute: triggers attributeChangedCallback
box.setAttribute('color', '#e74c3c');
// Console: Attribute "color" changed: "#3498db" → "#e74c3c"

box.setAttribute('color', '#2ecc71');
// Console: Attribute "color" changed: "#e74c3c" → "#2ecc71"

// Remove the attribute
box.removeAttribute('color');
// Console: Attribute "color" changed: "#2ecc71" → null

Important details:

  • attributeChangedCallback fires before connectedCallback if the element has attributes set in HTML. The browser parses attributes first, so your callback might fire when the element is not yet in the DOM.
  • It only fires for attributes listed in observedAttributes. Changes to unlisted attributes are silently ignored.
  • When oldValue is null, the attribute was just added. When newValue is null, the attribute was removed.

adoptedCallback()

Called when the element is moved to a new document via document.adoptNode(). This is a rare callback, primarily relevant when working with iframes or template documents.

class AdoptableWidget extends HTMLElement {
connectedCallback() {
console.log('Connected to', this.ownerDocument.title || 'untitled document');
}

adoptedCallback() {
console.log('Adopted into a new document');
}

disconnectedCallback() {
console.log('Disconnected');
}
}

customElements.define('adoptable-widget', AdoptableWidget);
// Move an element from the main document to an iframe's document
const widget = document.querySelector('adoptable-widget');
const iframe = document.querySelector('iframe');

// Adopt the node into the iframe's document
const adopted = iframe.contentDocument.adoptNode(widget);
// Console: "Disconnected" (removed from original document)
// Console: "Adopted into a new document"

iframe.contentDocument.body.appendChild(adopted);
// Console: "Connected to [iframe document title]"
info

In practice, adoptedCallback is rarely used. Most developers will never need it. The three core callbacks you will use regularly are connectedCallback, disconnectedCallback, and attributeChangedCallback.

Lifecycle Order

Understanding the order in which callbacks fire is important for avoiding bugs:

class LifecycleDemo extends HTMLElement {
constructor() {
super();
console.log('1. constructor');
}

static get observedAttributes() {
return ['data-name'];
}

attributeChangedCallback(name, oldValue, newValue) {
console.log(`2. attributeChangedCallback: ${name} = "${newValue}"`);
}

connectedCallback() {
console.log('3. connectedCallback');
}

disconnectedCallback() {
console.log('4. disconnectedCallback');
}
}

customElements.define('lifecycle-demo', LifecycleDemo);
<lifecycle-demo data-name="test"></lifecycle-demo>

Console output:

1. constructor
2. attributeChangedCallback: data-name = "test"
3. connectedCallback

When created from HTML, the order is: constructorattributeChangedCallback (for each observed attribute present in markup) → connectedCallback.

When removing and re-adding:

const el = document.querySelector('lifecycle-demo');

el.remove();
// Console: 4. disconnectedCallback

document.body.appendChild(el);
// Console: 3. connectedCallback
// (constructor does NOT run again - the object already exists)

observedAttributes Static Getter

The observedAttributes static getter is the gatekeeper for attributeChangedCallback. Only attributes listed in the array returned by this getter will trigger the callback. All other attribute changes are silently ignored.

Why Is This Required?

Performance. An element might have many attributes (classes, data attributes, ARIA attributes, styles), and listening to every single change would be wasteful. By declaring which attributes you care about, you tell the browser to only notify you about the relevant ones.

Declaring Observed Attributes

class ProgressBar extends HTMLElement {
static get observedAttributes() {
return ['value', 'max', 'label', 'color'];
}

constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: system-ui;
}
.container {
background: #eee;
border-radius: 8px;
overflow: hidden;
height: 24px;
position: relative;
}
.fill {
height: 100%;
border-radius: 8px;
transition: width 0.3s ease, background-color 0.3s ease;
}
.label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
font-weight: bold;
color: #333;
}
</style>
<div class="container">
<div class="fill"></div>
<span class="label"></span>
</div>
`;
}

attributeChangedCallback(name, oldValue, newValue) {
// Re-render whenever any observed attribute changes
this.render();
}

connectedCallback() {
this.render();
}

render() {
if (!this.shadowRoot) return;

const value = parseFloat(this.getAttribute('value')) || 0;
const max = parseFloat(this.getAttribute('max')) || 100;
const label = this.getAttribute('label') || '';
const color = this.getAttribute('color') || '#3498db';

const percentage = Math.min(100, Math.max(0, (value / max) * 100));

const fill = this.shadowRoot.querySelector('.fill');
const labelEl = this.shadowRoot.querySelector('.label');

fill.style.width = `${percentage}%`;
fill.style.backgroundColor = color;
labelEl.textContent = label || `${Math.round(percentage)}%`;
}
}

customElements.define('progress-bar', ProgressBar);
<progress-bar value="65" max="100" color="#27ae60" label="65% Complete"></progress-bar>
const bar = document.querySelector('progress-bar');

// These trigger attributeChangedCallback and re-render
bar.setAttribute('value', '80');
bar.setAttribute('color', '#e74c3c');
bar.setAttribute('label', 'Almost there!');

// This does NOT trigger attributeChangedCallback (not observed)
bar.setAttribute('data-id', '42');
bar.classList.add('highlighted');

Reflecting Properties to Attributes

A common pattern is creating JavaScript properties that mirror HTML attributes, keeping both in sync. This makes the element usable both declaratively (in HTML) and programmatically (in JavaScript):

class ToggleSwitch extends HTMLElement {
static get observedAttributes() {
return ['checked', 'disabled', 'label'];
}

constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-family: system-ui; }
:host([disabled]) { opacity: 0.5; cursor: not-allowed; }
.track {
width: 44px; height: 24px; background: #ccc; border-radius: 12px;
position: relative; transition: background 0.2s;
}
:host([checked]) .track { background: #3498db; }
.thumb {
width: 20px; height: 20px; background: white; border-radius: 50%;
position: absolute; top: 2px; left: 2px; transition: left 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
:host([checked]) .thumb { left: 22px; }
</style>
<div class="track"><div class="thumb"></div></div>
<span class="label-text"></span>
`;

this.shadowRoot.querySelector('.track').addEventListener('click', () => {
if (!this.disabled) {
this.checked = !this.checked;
this.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}

// Property reflects to attribute
get checked() {
return this.hasAttribute('checked');
}

set checked(val) {
if (val) {
this.setAttribute('checked', '');
} else {
this.removeAttribute('checked');
}
}

// Property reflects to attribute
get disabled() {
return this.hasAttribute('disabled');
}

set disabled(val) {
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}

// Property reflects to attribute
get label() {
return this.getAttribute('label') || '';
}

set label(val) {
this.setAttribute('label', val);
}

attributeChangedCallback(name, oldValue, newValue) {
if (name === 'label') {
const labelEl = this.shadowRoot.querySelector('.label-text');
if (labelEl) labelEl.textContent = newValue || '';
}
// 'checked' and 'disabled' are handled by CSS via :host([checked]) and :host([disabled])
}

connectedCallback() {
const labelEl = this.shadowRoot.querySelector('.label-text');
labelEl.textContent = this.label;
}
}

customElements.define('toggle-switch', ToggleSwitch);
<toggle-switch label="Dark Mode" checked></toggle-switch>
<toggle-switch label="Notifications"></toggle-switch>
<toggle-switch label="Unavailable" disabled></toggle-switch>
const toggle = document.querySelector('toggle-switch');

// Use as a property (JavaScript-friendly)
console.log(toggle.checked); // true
toggle.checked = false; // Removes the 'checked' attribute

// Use as an attribute (HTML-friendly)
toggle.setAttribute('checked', ''); // Same as toggle.checked = true

// Listen for changes
toggle.addEventListener('change', () => {
console.log('Toggle is now:', toggle.checked);
});
tip

Attribute-property reflection is what makes custom elements feel natural. Built-in elements do this too: setting input.value updates what the user sees, and setting the checked attribute on a checkbox changes checkbox.checked. Follow the same pattern in your custom elements to provide a familiar, intuitive API.

Customized Built-In Elements (is Attribute)

Instead of creating an entirely new element from scratch, you can extend an existing built-in element to add custom behavior while preserving all of its native functionality: accessibility, form integration, default styling, and built-in keyboard handling.

How It Works

Instead of extending HTMLElement, you extend the specific element class:

class ConfirmLink extends HTMLAnchorElement {
connectedCallback() {
this.addEventListener('click', (event) => {
const confirmed = confirm(`Navigate to ${this.href}?`);
if (!confirmed) {
event.preventDefault();
}
});
}
}

// Third argument: { extends: 'a' } tells the browser which built-in element you are extending
customElements.define('confirm-link', ConfirmLink, { extends: 'a' });

In HTML, you use the is attribute on the built-in element:

<!-- Note: you use <a>, not <confirm-link> -->
<a is="confirm-link" href="https://example.com">Click me (with confirmation)</a>

The element is still a <a> tag with all its native behavior (link styling, right-click context menu, href handling, accessibility, SEO crawling). Your custom class adds behavior on top.

More Examples

Enhanced button with loading state:

class LoadingButton extends HTMLButtonElement {
static get observedAttributes() {
return ['loading'];
}

constructor() {
super();
this._originalContent = '';
}

attributeChangedCallback(name, oldValue, newValue) {
if (name === 'loading') {
if (newValue !== null) {
// Entering loading state
this._originalContent = this.innerHTML;
this.innerHTML = '⏳ Loading...';
this.disabled = true;
this.style.opacity = '0.7';
} else {
// Exiting loading state
this.innerHTML = this._originalContent;
this.disabled = false;
this.style.opacity = '1';
}
}
}
}

customElements.define('loading-button', LoadingButton, { extends: 'button' });
<button is="loading-button" id="submitBtn">Submit Form</button>

<script>
const btn = document.getElementById('submitBtn');

btn.addEventListener('click', async () => {
btn.setAttribute('loading', '');

// Simulate async operation
await new Promise(resolve => setTimeout(resolve, 2000));

btn.removeAttribute('loading');
});
</script>

The button retains all native behavior: keyboard activation (Enter/Space), form submission, :disabled CSS pseudo-class, ARIA accessibility, focus management. Your customization only adds the loading state logic.

Auto-expanding textarea:

class AutoTextarea extends HTMLTextAreaElement {
connectedCallback() {
this.style.overflow = 'hidden';
this.style.resize = 'none';
this.style.minHeight = '40px';

this.addEventListener('input', () => this.autoResize());
// Initial resize in case there is pre-filled content
this.autoResize();
}

autoResize() {
this.style.height = 'auto';
this.style.height = this.scrollHeight + 'px';
}
}

customElements.define('auto-textarea', AutoTextarea, { extends: 'textarea' });
<textarea is="auto-textarea" placeholder="Type something... I'll grow!"></textarea>

Creating Customized Built-In Elements Programmatically

When creating extended built-in elements from JavaScript, use the is option:

// Using document.createElement
const link = document.createElement('a', { is: 'confirm-link' });
link.href = 'https://example.com';
link.textContent = 'Confirm before navigating';
document.body.appendChild(link);

// Using the constructor directly
const btn = new LoadingButton();
btn.textContent = 'Click me';
document.body.appendChild(btn);

The Safari Problem

There is an important caveat with customized built-in elements:

warning

Safari does not support customized built-in elements and Apple has stated they do not plan to implement them. This is the single biggest compatibility issue in the Web Components specifications.

Safari supports autonomous custom elements (class extends HTMLElement) perfectly, but refuses to support class extends HTMLButtonElement (or any other built-in).

Workarounds:

  1. Use autonomous custom elements instead: Create <fancy-button> that internally renders a <button> in its Shadow DOM. You lose native form integration but gain cross-browser compatibility.
  2. Use a polyfill: The @ungap/custom-elements package polyfills customized built-in elements in Safari.
npm install @ungap/custom-elements
<script src="https://unpkg.com/@ungap/custom-elements"></script>
  1. Feature detection:
function supportsCustomizedBuiltIns() {
try {
class TestElement extends HTMLParagraphElement {}
customElements.define('test-p-' + Date.now(), TestElement, { extends: 'p' });
return true;
} catch (e) {
return false;
}
}

if (!supportsCustomizedBuiltIns()) {
console.log('Loading polyfill for customized built-in elements');
// Load polyfill
}

For maximum compatibility, prefer autonomous custom elements unless you have a specific reason to extend a built-in (like form participation or accessibility features that are difficult to replicate).

Upgrading Elements

Upgrading is the process by which the browser converts an unknown element into a fully functional custom element. Understanding this process is important for handling timing issues and providing good user experiences.

How Upgrading Works

When the browser encounters an unknown tag in HTML (like <my-widget> before its definition has loaded), it creates an HTMLUnknownElement (in the case of elements without hyphens) or an HTMLElement (for elements with hyphens, which are treated as potential custom elements). When the corresponding customElements.define() call runs, the browser upgrades all existing instances.

<!-- Browser parses these BEFORE the script loads -->
<status-indicator status="online"></status-indicator>
<status-indicator status="offline"></status-indicator>

<!-- The definition loads later -->
<script>
class StatusIndicator extends HTMLElement {
static get observedAttributes() {
return ['status'];
}

constructor() {
super();
console.log('constructor called');
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
console.log('connectedCallback called');
this.render();
}

attributeChangedCallback() {
console.log('attributeChangedCallback called');
this.render();
}

render() {
if (!this.shadowRoot) return;
const status = this.getAttribute('status') || 'unknown';
const colors = { online: '#2ecc71', offline: '#e74c3c', unknown: '#95a5a6' };
this.shadowRoot.innerHTML = `
<style>
.dot {
display: inline-block;
width: 12px; height: 12px;
border-radius: 50%;
margin-right: 6px;
}
span { font-family: system-ui; font-size: 14px; }
</style>
<span>
<span class="dot" style="background: ${colors[status] || colors.unknown}"></span>
${status}
</span>
`;
}
}

customElements.define('status-indicator', StatusIndicator);
</script>

Console output when the script runs:

constructor called        (first element upgraded)
attributeChangedCallback called (status="online" parsed)
connectedCallback called (first element is already in DOM)
constructor called (second element upgraded)
attributeChangedCallback called (status="offline" parsed)
connectedCallback called (second element is already in DOM)

The browser retroactively applies the full lifecycle to each existing instance.

Waiting for Element Upgrade

Use customElements.whenDefined() to run code after a custom element has been defined:

// Returns a Promise that resolves when the element is defined
customElements.whenDefined('status-indicator').then(() => {
console.log('status-indicator is now defined');

// Safe to query and use the element's custom API
document.querySelectorAll('status-indicator').forEach(el => {
console.log('Status:', el.getAttribute('status'));
});
});

With async/await:

async function init() {
await customElements.whenDefined('status-indicator');

const indicators = document.querySelectorAll('status-indicator');
console.log(`Found ${indicators.length} status indicators`);
}

init();

Handling the Pre-Upgrade State with CSS

Before an element is upgraded, it has no Shadow DOM, no internal rendering, and no custom behavior. You should handle this state visually:

/* Before definition: show a placeholder or hide the element */
status-indicator:not(:defined) {
display: inline-block;
width: 80px;
height: 16px;
background: #eee;
border-radius: 8px;
animation: pulse 1.5s infinite;
}

@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}

/* After definition: element renders normally via its Shadow DOM */
status-indicator:defined {
/* No special styles needed - the element handles its own rendering */
}

This creates a smooth loading experience: users see a pulsing placeholder until the component's JavaScript loads, then a seamless transition to the real content.

Manual Upgrade

In rare cases, you might need to trigger an upgrade manually. The customElements.upgrade() method forces the upgrade of an element that was created outside the document:

// Create an element in a detached fragment
const fragment = new DocumentFragment();
const el = document.createElement('my-widget');
fragment.appendChild(el);

// At this point, el is NOT upgraded even if 'my-widget' is defined,
// because it was never connected to a document

// Force the upgrade
customElements.upgrade(el);

// Now el's constructor has run, and it is fully initialized
// (but connectedCallback has NOT run - it is not in the DOM yet)

This is primarily useful for pre-initializing elements before adding them to the DOM.

Constructor Rules and Best Practices

The constructor of a custom element has specific rules that the browser enforces. Violating them causes errors.

What You Can Do in the Constructor

class MyElement extends HTMLElement {
constructor() {
super(); // MUST be the first statement

// Attach Shadow DOM
this.attachShadow({ mode: 'open' });

// Set up internal state
this._count = 0;
this._handlers = new Map();

// Add event listeners to the element itself or its shadow root
this.addEventListener('click', this._handleClick.bind(this));

// Set up the shadow DOM structure
this.shadowRoot.innerHTML = `<p>Initial content</p>`;
}
}

What You Cannot Do in the Constructor

class BadElement extends HTMLElement {
constructor() {
super();

// WRONG: Do not read attributes in the constructor
// attributeChangedCallback has not fired yet for HTML-parsed elements
const name = this.getAttribute('name'); // May work but is unreliable

// WRONG: Do not add children to the light DOM
this.innerHTML = '<p>Hello</p>'; // Throws InvalidStateError

// WRONG: Do not add attributes
this.setAttribute('role', 'button'); // Throws error during upgrade
}
}

The correct approach:

class GoodElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Keep the constructor minimal
}

connectedCallback() {
// Safe to read attributes here
const name = this.getAttribute('name');

// Safe to modify light DOM here (though Shadow DOM is preferred)
// this.innerHTML = '<p>Hello</p>';

// Safe to add attributes here
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'button');
}

// Render into Shadow DOM
this.render();
}

render() {
const name = this.getAttribute('name') || 'World';
this.shadowRoot.innerHTML = `<p>Hello, ${name}!</p>`;
}
}
caution

The constructor rules exist because of the upgrade process. When the browser upgrades an existing element, it runs the constructor on an element that already exists in the DOM with attributes and children. Adding children or attributes in the constructor would conflict with the existing content. Keep the constructor minimal: call super(), attach Shadow DOM, set up internal state. Do everything else in connectedCallback.

Practical Example: Building a Complete Custom Element

Here is a comprehensive, production-quality example that demonstrates all the concepts covered in this guide. It creates a <star-rating> element:

class StarRating extends HTMLElement {
static get observedAttributes() {
return ['value', 'max', 'readonly'];
}

constructor() {
super();
this.attachShadow({ mode: 'open' });

this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
gap: 2px;
cursor: pointer;
font-size: 24px;
user-select: none;
}
:host([readonly]) {
cursor: default;
}
.star {
transition: transform 0.1s;
display: inline-block;
}
.star:hover {
transform: scale(1.2);
}
:host([readonly]) .star:hover {
transform: none;
}
</style>
<div class="stars"></div>
`;

// Bind event handlers
this.shadowRoot.querySelector('.stars').addEventListener('click', (e) => {
if (this.readonly) return;

const star = e.target.closest('.star');
if (!star) return;

const newValue = parseInt(star.dataset.value, 10);
this.value = newValue;

this.dispatchEvent(new CustomEvent('rating-change', {
bubbles: true,
composed: true,
detail: { value: newValue, max: this.max }
}));
});

// Hover preview
this.shadowRoot.querySelector('.stars').addEventListener('mouseover', (e) => {
if (this.readonly) return;

const star = e.target.closest('.star');
if (!star) return;

const hoverValue = parseInt(star.dataset.value, 10);
this._renderStars(hoverValue);
});

this.shadowRoot.querySelector('.stars').addEventListener('mouseout', () => {
if (this.readonly) return;
this._renderStars(this.value);
});
}

// Property-attribute reflection
get value() {
return parseInt(this.getAttribute('value'), 10) || 0;
}

set value(val) {
this.setAttribute('value', String(Math.max(0, Math.min(val, this.max))));
}

get max() {
return parseInt(this.getAttribute('max'), 10) || 5;
}

set max(val) {
this.setAttribute('max', String(Math.max(1, val)));
}

get readonly() {
return this.hasAttribute('readonly');
}

set readonly(val) {
if (val) {
this.setAttribute('readonly', '');
} else {
this.removeAttribute('readonly');
}
}

attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this._renderStars(this.value);
}

connectedCallback() {
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'slider');
this.setAttribute('aria-valuemin', '0');
}
this._renderStars(this.value);
}

_renderStars(highlightCount) {
const container = this.shadowRoot.querySelector('.stars');
if (!container) return;

const max = this.max;
container.innerHTML = '';

for (let i = 1; i <= max; i++) {
const star = document.createElement('span');
star.classList.add('star');
star.dataset.value = i;
star.textContent = i <= highlightCount ? '★' : '☆';
container.appendChild(star);
}

// Update ARIA attributes
this.setAttribute('aria-valuenow', String(this.value));
this.setAttribute('aria-valuemax', String(max));
this.setAttribute('aria-valuetext', `${this.value} out of ${max} stars`);
}
}

customElements.define('star-rating', StarRating);
<!-- Declarative usage -->
<star-rating value="3" max="5"></star-rating>

<!-- Read-only display -->
<star-rating value="4" max="5" readonly></star-rating>

<!-- Custom max -->
<star-rating value="7" max="10"></star-rating>

<script>
// Programmatic usage
const rating = document.querySelector('star-rating');

// Property access
console.log(rating.value); // 3
console.log(rating.max); // 5

// Property assignment (reflects to attributes)
rating.value = 4;

// Listen for changes
rating.addEventListener('rating-change', (e) => {
console.log(`New rating: ${e.detail.value}/${e.detail.max}`);
});
</script>

This component demonstrates:

  • Autonomous custom element extending HTMLElement
  • Shadow DOM for style encapsulation
  • observedAttributes and attributeChangedCallback for reactive attributes
  • Property-attribute reflection for a natural API
  • connectedCallback for initial setup and ARIA attributes
  • Custom events with composed: true to cross the shadow boundary
  • Accessibility with ARIA slider attributes
  • Interactive behavior with hover preview and click handling

Summary

Custom Elements are the foundation of Web Components, giving you the power to define new HTML tags with custom behavior, attributes, and lifecycle management.

  • Autonomous custom elements extend HTMLElement to create entirely new tags. The tag name must contain a hyphen to avoid conflicts with existing and future standard elements.
  • customElements.define(name, class) registers your element class with the browser. Each name and each class can only be registered once.
  • Lifecycle callbacks let you react to the element's life in the DOM:
    • connectedCallback: element added to the DOM (setup, rendering)
    • disconnectedCallback: element removed from the DOM (cleanup)
    • attributeChangedCallback: an observed attribute changed (react to changes)
    • adoptedCallback: element moved to a new document (rare)
  • observedAttributes is a required static getter that lists which attributes trigger attributeChangedCallback. Unlisted attributes are silently ignored.
  • Customized built-in elements extend specific HTML elements (like HTMLButtonElement) using the is attribute, preserving all native behavior. However, Safari does not support them, so prefer autonomous elements for cross-browser compatibility, or use a polyfill.
  • Element upgrading is the process by which the browser retroactively initializes custom elements when their definition becomes available. Use :defined / :not(:defined) CSS pseudo-classes and customElements.whenDefined() to handle the pre-upgrade state gracefully.