Chapter 82: Custom Elements

Custom Elements Overview & Registration

Defining custom HTML tags, the `CustomElementRegistry` API, naming grammar rules, element upgrade mechanics, and `:not(:defined)` FOUC prevention.

LEARNING OBJECTIVES
  • Understand the role of the CustomElementRegistry and how to register tags using customElements.define().
  • Master the WHATWG naming grammar rules for custom elements (including the mandatory hyphen and prohibited tag names).
  • Explain the lifecycle of DOM element upgrades from HTMLUnknownElement / HTMLElement to registered custom instances.
  • Eliminate Flash of Unstyled Content (FOUC) during asynchronous component loading using the CSS :not(:defined) pseudo-class.
🎬 INTERACTIVE VISUAL PIPELINE Core Architecture Simulation
🌐
1. Input
Directives & Tags
⚙️
2. Parse
Tokenizer & AST
🌳
3. Layout
Box Model & Flow
🎨
4. Render
GPU Paint & Composite
PHASE 1: INPUT & DIRECTIVES
Browser receives declarative markup stream, parsing tag tokens and initializing component state.

📖 The Mental Model & Story (Intuitive Foundation)

Imagine purchasing a modular toy construction set like LEGO. For decades, the factory gave you fixed, predefined blocks: 2x4 red bricks, 1x2 blue plates, and transparent windows. If you wanted to build a complex robotic arm, you had to glue dozens of tiny generic bricks together with stickers and rubber bands. If another child visited your play area, they had no idea which clump of bricks formed the robotic arm without reading your private notebook.

Now imagine the toy company provides you with a Standard 3D Mold Injector (CustomElementRegistry). You can design a brand-new, single-piece component called <robotic-arm> and register its blueprint with the factory.

When the blueprint is registered:

  1. Anyone can place <robotic-arm> directly on the construction board (the HTML DOM).
  2. The board immediately recognizes it as an official, first-class toy piece.
  3. If someone places a <robotic-arm> on the board before you load the blueprint, the board keeps it safe as an un-molded placeholder. The moment your blueprint arrives, the board upgrades the placeholder instantly into the fully functional robotic arm.
                  +----------------------------------------------+
                  |         HTML Document Parser                 |
                  | Encounters unknown tag: <user-badge>         |
                  +----------------------------------------------+
                                         |
                                         v
                  +----------------------------------------------+
                  |  State 1: UNREGISTERED / UNRESOLVED          |
                  |  - Instance: HTMLElement                     |
                  |  - Matches CSS: :not(:defined)               |
                  |  - Inert: No custom methods or behaviors    |
                  +----------------------------------------------+
                                         |
                 customElements.define('user-badge', UserBadge)
                                         |
                                         v
                  +----------------------------------------------+
                  |  State 2: UPGRADED & DEFINED                 |
                  |  - Instance: UserBadge extends HTMLElement   |
                  |  - Matches CSS: :defined                     |
                  |  - Active: Lifecycle callbacks & methods run|
                  +----------------------------------------------+

Technical Deep Dive & Specifications

The CustomElementRegistry Interface

Every browser window exposes a global registry instance at window.customElements. It implements the CustomElementRegistry interface:

Method / Property Signature Purpose & Specification Behavior
define() define(name, constructor, options?) Registers a new custom element. Throws NotSupportedError if name is invalid or already registered.
get() get(name) Returns the constructor for the named custom element, or undefined if not registered.
whenDefined() whenDefined(name) Returns a Promise<CustomElementConstructor> that resolves when the named element is registered.
upgrade() upgrade(rootNode) Synchronously upgrades all custom elements within a DOM subtree rooted at rootNode.
+-----------------------------------------------------------------------------------------------+
|                                    window.customElements                                      |
|                                                                                               |
|   +-----------------------+   +-----------------------+   +-------------------------------+   |
|   | .define('app-card', C)|   | .get('app-card')      |   | .whenDefined('app-card')      |   |
|   | Registers class 'C'   |   | Returns class C       |   | Returns Promise for lazy load |   |
|   +-----------------------+   +-----------------------+   +-------------------------------+   |
+-----------------------------------------------------------------------------------------------+

Valid Custom Element Naming Rules (WHATWG Specification)

The HTML Living Standard enforces strict lexical constraints on custom element names:

  1. Mandatory Hyphen (-): Must contain at least one hyphen (e.g., user-profile, fancy-button, my-super-long-tag-name). This ensures forward compatibility so the W3C/WHATWG can introduce new single-word HTML tags (like <dialog> or <search>) without breaking user code.
  2. First Character Restriction: Must begin with an ASCII lowercase letter (a-z).
  3. Prohibited Characters: Cannot contain uppercase ASCII characters (A-Z). Tag names in HTML are case-insensitive when parsed, but custom element definitions are strictly lowercase.
  4. PCENChar (Potential Custom Element Name Character): Characters after the first letter may include a-z, 0-9, -, ., _, and specific Unicode character ranges.
  5. Reserved / Forbidden Names: The following hyphenated names are strictly forbidden because they collide with legacy SVG or MathML specifications:
    • annotation-xml
    • color-profile
    • font-face
    • font-face-src
    • font-face-uri
    • font-face-format
    • font-face-name
    • missing-glyph
VALID:
  <user-avatar>        -> Starts with 'u', contains '-'
  <chart-2d>           -> Contains digits and '-'
  <super-cool-button>  -> Multiple hyphens allowed

INVALID:
  <useravatar>         -> Throws NotSupportedError (missing hyphen)
  <2-gauge>            -> Throws NotSupportedError (must start with a-z)
  <user-Card>          -> Throws NotSupportedError (uppercase letters forbidden)
  <font-face>          -> Throws NotSupportedError (reserved legacy name)

The Element constructor() Rules

When writing the ES class for a custom element, the constructor() must adhere to strict browser invariants:

class MyElement extends HTMLElement {
  constructor() {
    // 1. MUST call super() first to establish prototype chain and engine binding
    super();

    // 2. DO NOT inspect attributes (this.getAttribute) -> Attributes not yet parsed!
    // 3. DO NOT inspect or mutate children (this.appendChild) -> Children not yet attached!
    // 4. DO initialize private state, bind methods, and attach Shadow DOM
    this._count = 0;
  }
}

Flash of Unstyled Content (FOUC) & :not(:defined)

When a browser parses an HTML document containing <custom-card>, it renders the raw DOM node before your JavaScript file downloads and executes customElements.define().

To prevent unstyled layout shifts, use the :not(:defined) pseudo-class:

/* Hide or display a skeleton placeholder until the JS class is registered */
user-badge:not(:defined) {
  display: inline-block;
  min-width: 120px;
  min-height: 32px;
  background: #e2e8f0;
  border-radius: 9999px;
  opacity: 0.5;
  animation: pulse 1.5s infinite;
}

user-badge:defined {
  display: inline-flex;
  opacity: 1;
  transition: opacity 0.2s ease-in;
}

💻 Interactive Code Playground

Starter Code

Line-by-Line Code Breakdown

  • Lines 14–22: user-badge:not(:defined) targets the element while it exists in the DOM as an unresolved node, rendering a pulsing skeleton box to prevent layout jumping.
  • Lines 24–33: user-badge:defined styles the element once customElements.define() executes and upgrades the node.
  • Line 41: <user-badge> is parsed into the DOM immediately. Because the browser hasn't encountered the definition yet, it creates a base HTMLElement instance.
  • Lines 47–59: Class UserBadge extends HTMLElement. constructor() invokes super().
  • Lines 62–65: customElements.whenDefined('user-badge') returns a Promise that settles as soon as registration occurs.
  • Lines 68–71: customElements.define('user-badge', UserBadge) officially binds the tag name to the class constructor.

Expected Browser Render Output

  1. 0 to 2 Seconds: The page displays a pulsing gray pill-shaped placeholder (:not(:defined)). Status indicates "Waiting for registration...".
  2. At 2 Seconds: The Promise resolves, status turns green ("Registered & Upgraded!"), and the skeleton immediately transforms into a styled badge showing a green online dot, "Alex Rivera", and an "Admin" tag.

SYS: ACTIVE
HULL: 98%
CORE: STABLE
NET: ONLINE
HTML STARSHIP CODE TERMINAL playground.html
LIVE RENDER & DIAGNOSTICS CORE TEMP: 45°C
INSPECTING DOM: VALID
TAGS: SCANNING...

🏋️ Hands-On Exercise

🎯 The Challenge: Register a <time-ago> Component

Instructions:

  1. Create a custom element class named TimeAgo extending HTMLElement.
  2. Register the tag <time-ago> with customElements.define().
  3. In connectedCallback(), read the datetime attribute (an ISO date string) and calculate relative time (e.g., "5 minutes ago", "Just now", "2 hours ago").
  4. Add CSS :not(:defined) styling to display an empty placeholder before registration.
  5. Use customElements.whenDefined() to log a confirmation message to the console.

🏁 Starter Code Sandbox

SYS: ACTIVE
HULL: 98%
CORE: STABLE
NET: ONLINE
STARTER CODE SANDBOX exercise.html
LIVE RENDER & DIAGNOSTICS CORE TEMP: 45°C
INSPECTING DOM: VALID
TAGS: SCANNING...

⚠️ Common Pitfalls

  1. Omitting the Hyphen: Registering customElements.define('userbadge', UserBadge) throws an immediate DOMException: Failed to execute 'define' on 'CustomElementRegistry': "userbadge" is not a valid custom element name. Always use kebab-case with at least one hyphen (e.g., user-badge).
  2. Performing DOM Operations in constructor(): Calling this.innerHTML = '...' or this.getAttribute('data-id') inside the constructor() throws an error or returns null because attributes and child nodes have not yet been parsed. Always defer DOM creation and attribute reading to connectedCallback().
  3. Forgetting super(): In ES classes extending HTMLElement, omitting super() inside the constructor() will throw a ReferenceError: Must call super constructor before accessing 'this'.
  4. Duplicate Registration: Calling customElements.define('my-tag', MyTag) twice for the same tag name throws a NotSupportedError. Guard registrations in modular environments using if (!customElements.get('my-tag')) customElements.define('my-tag', MyTag);.

💡 Pro Tips

  1. Lazy Loading with whenDefined(): Combine dynamic import() with customElements.whenDefined() to defer downloading heavy component classes until the element is actually present in the DOM.
  2. Feature Detection: Check standard support before executing component bundles:
    if ('customElements' in window) {
      // Native support verified
    }
    
  3. Side-Effect Imports: Export your registration as a self-registering module (import './components/user-badge.js') while also exporting the un-registered class for unit testing (export { UserBadge }).

📌 Key Takeaways

  • Custom elements allow developers to create custom, reusable HTML tags with encapsulated behavior.
  • All custom element tag names must contain a hyphen (-) and start with an ASCII lowercase letter to avoid future standard HTML namespace collisions.
  • The constructor() is strictly for internal state setup; DOM rendering and attribute inspection must wait for connectedCallback().
  • The browser upgrades existing DOM elements automatically the moment customElements.define() is called.
  • Use the :not(:defined) CSS pseudo-class to prevent FOUC (Flash of Unstyled Content) during asynchronous script loading.
  • --
⭐ LEARN: HTML 🌟 ⚔️ QUIZ BATTLE ARENA // ACTIVE
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 1 / 3

Which of the following is a valid custom element tag name according to the WHATWG specification?

Question 1 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 2 / 3

What happens if an HTML parser encounters <app-header> in a document before customElements.define('app-header', AppHeader) is called?

Question 2 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 3 / 3

Why should you never inspect attributes via this.getAttribute() inside the class constructor()?

Question 3 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP