Semantic markers become real behavior here. customElements.define() associates an element name with a class extending HTMLElement, and the browser starts calling its lifecycle callbacks. The line that trips people up most is the constructor versus connectedCallback split โ one runs before the element is anywhere near the document, the other runs once it is, and mixing them up is the most common source of "why is this null" bugs in a first custom element.
Learning Objectives
Register custom elements using customElements.define() to make them web components
Understand element constructor patterns
Introduce the basics of the lifecycle of web components
Implement common DOM manipulation in and using web components
The Custom Elements API
The Custom Elements API allows you to define new HTML elements with JavaScript logic. You register a custom element and associate it with a class:
customElements.define('my-element', MyElement);
Basic Custom Element Class
Every custom element extends HTMLElement and must call super() first in the constructor:
class MyElement extends HTMLElement {
constructor() {
super(); // Must call super() first!
// Element initialization
this._data = {}; // Underscore convention for "private" properties
}
connectedCallback() {
// Called when element is added to the document
this.render();
}
render() {
// Update element content
this.innerHTML = `
Hello from MyElement!
`;
}
}
// Register the element
customElements.define('my-element', MyElement);
Registration Requirements
Class-based definition: Must use ES6 classes
Extends HTMLElement: Or a specific HTML element if the idea was fully realized (not sadly due to Apple holding out)
A basic custom element that reads an attribute of the element and displays a default or custom greeting.
JavaScript Implementation
class GreetingElement extends HTMLElement {
constructor() {
super();
this._name = 'World'; // Default value
}
connectedCallback() {
// Read the 'name' attribute
this._name = this.getAttribute('name') || 'World';
this.render();
}
render() {
this.textContent = `Hello, ${this._name}! ๐`;
}
}
// Register the element
customElements.define('greeting-element', GreetingElement);
HTML Usage
<!-- With default name -->
<greeting-element></greeting-element>
<!-- With custom name attribute -->
<greeting-element name="Again"></greeting-element>
<!-- Child text here is discarded: render() overwrites it with textContent -->
<greeting-element name="Yet Again">This child text gets overwritten!</greeting-element>
Live Demo
Demo 2: Interactive Counter Element
A custom element with internal state and event handling. Demonstrates the full lifecycle of a component with user interaction.
<!-- Counter starting at 0 -->
<counter-element initial="0"></counter-element>
<!-- Counter starting at 10 -->
<counter-element initial="10"></counter-element>
Live Demo
Demo 3: User Card from slot Attributes
This component takes its content from its children instead of from attributes: each child carries a slot="โฆ" attribute saying which part of the card it belongs to, and the component reads those children and builds the card from them.
JavaScript Implementation
class UserCard extends HTMLElement {
connectedCallback() {
this.render();
}
render() {
// Read the marked children *before* overwriting them, since the
// innerHTML assignment below destroys the originals.
const name = this.querySelector('[slot="name"]')?.textContent || 'Unknown';
const role = this.querySelector('[slot="role"]')?.textContent || 'No role';
const email = this.querySelector('[slot="email"]')?.textContent || 'No email';
this.innerHTML = `
<div class="card-header">${name}</div>
<div class="card-info"><strong>Role:</strong> ${role}</div>
<div class="card-info"><strong>Email:</strong> ${email}</div>
`;
}
}
customElements.define('user-card', UserCard);
Elements can be "upgraded" (have their behavior added) at different times:
Elements in HTML before registration: Upgraded when defined
Elements created with document.createElement(): Upgraded when appended
Elements parsed after registration: Upgraded immediately
Elements cloned from templates: Upgraded when added to DOM
Checking Registration Status
// Check if element is defined
if (customElements.get('my-element')) {
console.log('Element is registered');
}
// Wait for element definition
customElements.whenDefined('my-element').then(() => {
console.log('Element is now defined and ready');
});
// Check if an element has been upgraded
const element = document.querySelector('my-element');
if (element instanceof MyElement) {
console.log('Element has been upgraded');
}
Demonstration
<!-- This element exists before definition -->
<delayed-element>
I'm waiting to be upgraded. My behavior will be added when registered.
</delayed-element>
<button id="register-delayed">Register Delayed Element</button>
<script>
document.getElementById('register-delayed').addEventListener('click', () => {
class DelayedElement extends HTMLElement {
connectedCallback() {
this.classList.add('upgraded');
this.innerHTML = `
<strong>Woo hoo I've been upgraded!</strong><br>
I'm now a proper custom element with behavior.
`;
}
}
customElements.define('delayed-element', DelayedElement);
});
</script>
Live Demo
This frame combines the delayed-registration demo with the registration status list below, so clicking the button shows both effects at once: the element upgrading in place, and its status flipping from โ Not Registered to โ Registered.
Waiting for a definition
Upgrade is asynchronous, so "is this element ready?" is a real question with two different answers. customElements.get() asks it synchronously; customElements.whenDefined() returns a promise that resolves whenever the definition lands, even if that is later.
// Synchronous: is it defined right now?
if (!customElements.get('user-card')) {
console.log('not registered yet');
}
// Asynchronous: tell me when it is, whenever that happens
await customElements.whenDefined('user-card');
document.querySelector('user-card').refresh();
CSS asks the same question with :defined, which is how you avoid a flash of un-upgraded content while the defining script is still loading.
user-card:not(:defined) { visibility: hidden; }
Registration Status
You can programmatically check which custom elements have been registered using the global customElements object โ an instance of the CustomElementRegistry interface โ in this case its get() method. Reload the page if you already registered it and see it showing as "Not Registered" and then register it to see it show the new state.
See it in action in the live demo at the end of the previous section โ that frame includes a <registration-status> element alongside the delayed-registration button, so registering delayed-element updates this list in real time.
Dynamic Element Creation
Custom elements can be created programmatically using document.createElement(), just like native HTML elements. Remember, the DOM manipulates arbitrary elements and custom elements are included. In a sense the DOM is a complete framework API for HTML plus whatever you invent. That shouldn't surprise you because the frameworks ultimately use it to do their job. Such things may provide better developer ergonomics, but fundamentally they rely on all the DOM concepts you should understand.
Implementation
// Create a custom element
const counter = document.createElement('counter-element');
// Set attributes before adding to DOM
counter.setAttribute('initial', '0');
// Add to the DOM (triggers connectedCallback)
document.getElementById('container').appendChild(counter);
// Or create and configure in one step
const greeting = document.createElement('greeting-element');
greeting.setAttribute('name', 'Dynamic');
document.body.appendChild(greeting);
Nothing about a custom element requires it to start out in the HTML โ createElement() plus appendChild() triggers the same connectedCallback that parsing the tag from markup does.
Autonomous vs Customized Built-in Elements
โ Autonomous Elements
Extend HTMLElement directly
class MyElement extends HTMLElement {
// Your code
}
customElements.define('my-element', MyElement);
Use in HTML:
<my-element></my-element>
โ Fully supported across all browsers
โ Customized Built-in Elements
Extend specific elements (e.g., HTMLButtonElement)
class FancyButton extends HTMLButtonElement {
// Your code
}
customElements.define('fancy-button', FancyButton,
{ extends: 'button' });
Use in HTML:
<button is="fancy-button"></button>
โ Limited browser support - avoid using
Best Practices
Next Steps
In Step 3: Component basics, we'll explore:
Attributes versus properties, and when to use which
Reflection: keeping the two in sync
observedAttributes and attributeChangedCallback
Custom states with ElementInternals and CSS :state()
Component API design โ and when HTML already has the element you are rebuilding