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);
๐ก Naming Convention: We use PascalCase for class names (e.g., MyElement) and kebab-case for element names (e.g., <my-element>). They don't have to match, but it's a helpful convention.
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) - Super call: Constructor must call
super() first - Registration: Use
customElements.define()
Constructor vs connectedCallback
Constructor
For setup that doesn't touch the Light DOM.
constructor() {
super();
// Initialize properties
this._count = 0;
this._name = 'Default';
// Setup internal state
this._data = {};
}
Use with Shadow DOM
connectedCallback
For Light DOM manipulation and setup.
connectedCallback() {
// Read attributes
this._name = this.getAttribute('name');
// Manipulate DOM
this.render();
// Attach event listeners
this.attachEventListeners();
}
Mostly for Light DOM solutions
โ ๏ธ Important: Don't manipulate the Light DOM in the constructor! Wait for connectedCallback to ensure the element is in the document.
Demo 1: Simple Greeting Element
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.
JavaScript Implementation
class CounterElement extends HTMLElement {
constructor() {
super();
this._count = 0;
}
connectedCallback() {
// Get initial count from attribute
const initial = this.getAttribute('initial');
this._count = initial ? parseInt(initial, 10) : 0;
this.render();
this.attachEventListeners();
}
render() {
this.innerHTML = `
<div class="counter-container">
<button class="decrement">-</button>
<span class="count">${this._count}</span>
<button class="increment">+</button>
</div>
`;
}
attachEventListeners() {
const decrementBtn = this.querySelector('.decrement');
const incrementBtn = this.querySelector('.increment');
decrementBtn.addEventListener('click', () => {
this._count--;
this.updateCount();
});
incrementBtn.addEventListener('click', () => {
this._count++;
this.updateCount();
});
}
updateCount() {
// Only update the count display, not the entire element
const countDisplay = this.querySelector('.count');
countDisplay.textContent = this._count;
}
}
customElements.define('counter-element', CounterElement);
HTML Usage
<!-- Counter starting at 0 -->
<counter-element initial="0"></counter-element>
<!-- Counter starting at 10 -->
<counter-element initial="10"></counter-element>
Live Demo
๐ก Performance Tip: Notice how updateCount() only updates the count display instead of re-rendering the entire element. This is more efficient for frequent updates, but full re-renders in simple cases likely is fine.
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.
โ ๏ธ These are not real slots. A
<slot> element only projects content inside a
shadow root. This component has none, so it uses the
slot attribute purely as a marker it reads with
querySelector('[slot="name"]') โ a poor man's projection. If you put a literal
<slot> element in the light DOM it renders its fallback text and projects nothing. Real slots,
<template>, and
slotchange arrive in
step 6.
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);
๐ก Div Output!?!: Note here that we are emitting <div> soup in a sense. You could do inert custom elements or standard HTML. Interestingly, most web components (and of course React components) do this. We don't have to forget core web dev ideas just because we are in a different context.
HTML Usage
<user-card>
<span slot="name">John Doe</span>
<span slot="role">Software Engineer</span>
<span slot="email">john@example.com</span>
</user-card>
Live Demo
When Elements Upgrade
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.
Implementation
class RegistrationStatus extends HTMLElement {
connectedCallback() {
this.render();
}
render() {
const elements = [
'greeting-element',
'counter-element',
'user-card',
'delayed-element',
'registration-status'
];
const statusHTML = elements.map(name => {
const isRegistered = customElements.get(name);
const status = isRegistered ? 'โ Registered' : 'โ Not Registered';
const className = isRegistered ? 'registered' : 'not-registered';
return `<li class="${className}"><code>${name}</code>: ${status}</li>`;
}).join('');
this.innerHTML = `
<div class="registration-status">
<h4>Custom Elements Registry:</h4>
<ul>${statusHTML}</ul>
</div>
`;
}
}
customElements.define('registration-status', RegistrationStatus);
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.
๐ก Note: Elements created with createElement() are upgraded immediately if the custom element is already registered, or will be upgraded later when the definition is registered.
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
โ ๏ธ Compatibility Warning: Customized built-in elements have limited support, particularly in WebKit-based browsers (Safari). Stick to autonomous custom elements for maximum compatibility.
Best Practices
Key Principles
- Call
super() first: Always call it before accessing this - Use connectedCallback for DOM work: Don't manipulate DOM in constructor
- Separate render and update: Use targeted updates for better performance
- Clean up in
disconnectedCallback: Remove event listeners when element is removed - Use underscore for internal properties: Convention for private members
- Provide default values: Always have fallbacks for attributes
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