Step 02

Custom elements

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);
๐Ÿ’ก 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