|$ curl https://forge-ai.dev/api/markdown?path=docs/html/custom-elements
$cat docs/custom-elements.md
updated Last week·45 min read·published

Custom Elements

HTMLWeb ComponentsReferenceIntermediate
Introduction

Custom Elements are a cornerstone of the Web Components specification, enabling developers to define their own HTML elements with encapsulated behavior and styling. They bridge the gap between standard HTML and reusable component-based architecture, allowing you to create rich, interactive elements that work seamlessly across frameworks.

A custom element is essentially a JavaScript class that extends HTMLElement (or a specialized interface like HTMLButtonElement). The browser's Custom Elements API handles registration, lifecycle management, and attribute observation automatically.

info

Custom Elements are supported in all modern browsers. They require no polyfills for basic usage. IE11 requires a polyfill from the webcomponents.js project.
Lifecycle Callbacks

Custom elements have a well-defined lifecycle with five distinct callback methods. The browser invokes these callbacks automatically as the element is created, added, moved, or removed from the DOM.

constructor

Called when the element is created. Use it to set up initial state, create shadow DOM, and attach event listeners. You must call super() first and avoid reading attributes or inspecting the DOM at this stage.

constructor.js
JavaScript
1class MyElement extends HTMLElement {
2 constructor() {
3 super();
4 this._count = 0;
5 this.attachShadow({ mode: 'open' });
6 this.shadowRoot.innerHTML = `
7 <button id="btn">Click me</button>
8 `;
9 }
10}

connectedCallback

Invoked when the element is inserted into the DOM. This is the correct place to perform rendering, fetch resources, add document-level event listeners, and set up observers like IntersectionObserver.

connected.js
JavaScript
1connectedCallback() {
2 this._render();
3 this._observer = new IntersectionObserver(
4 (entries) => {
5 if (entries[0].isIntersecting) {
6 this.setAttribute('visible', '');
7 }
8 }
9 );
10 this._observer.observe(this);
11}
12
13disconnectedCallback() {
14 this._observer?.disconnect();
15 this._observer = null;
16}

disconnectedCallback

Called when the element is removed from the DOM. Clean up all resources: remove event listeners, disconnect observers, cancel timers, and release references to prevent memory leaks.

disconnected.js
JavaScript
1disconnectedCallback() {
2 this._resizeObserver?.disconnect();
3 this._timer && clearInterval(this._timer);
4 this._abortController?.abort();
5 // Remove global listeners added in connectedCallback
6 document.removeEventListener('scroll', this._boundHandler);
7}

adoptedCallback

Fired when the element is moved into a new document (e.g., when using document.adoptNode). Use this to reinitialize document-specific state like event listeners tied to the document object.

adopted.js
JavaScript
1adoptedCallback() {
2 // Re-attach document-level listeners
3 document.addEventListener('keydown', this._handleKeydown);
4}

attributeChangedCallback

Called whenever an observed attribute changes value. It receives the attribute name, the old value, and the new value. This is the primary mechanism for reacting to external configuration changes.

attributeChanged.js
JavaScript
1static get observedAttributes() {
2 return ['disabled', 'value', 'placeholder'];
3}
4
5attributeChangedCallback(name, oldVal, newVal) {
6 if (oldVal === newVal) return;
7 switch (name) {
8 case 'disabled':
9 this._updateDisabled(newVal !== null);
10 break;
11 case 'value':
12 this._updateValue(newVal);
13 break;
14 case 'placeholder':
15 this._updatePlaceholder(newVal);
16 break;
17 }
18}

warning

The attributeChangedCallback only fires for attributes listed in observedAttributes. Changes to unlisted attributes do not trigger this callback. Always guard against oldVal === newVal to avoid unnecessary re-renders.
observedAttributes

The static observedAttributes getter returns an array of attribute names that the custom element should monitor. When any of these attributes change value, the attributeChangedCallback is invoked. This is the declarative way to expose element configuration through HTML attributes.

Boolean attributes (like disabled) are considered present when they exist on the element, regardless of their value. Reflecting properties to attributes keeps the DOM in sync with the JavaScript state.

observed-attributes.js
JavaScript
1class ToggleSwitch extends HTMLElement {
2 static get observedAttributes() {
3 return ['checked', 'disabled', 'label'];
4 }
5
6 get checked() {
7 return this.hasAttribute('checked');
8 }
9
10 set checked(val) {
11 this.toggleAttribute('checked', val);
12 }
13
14 get disabled() {
15 return this.hasAttribute('disabled');
16 }
17
18 set disabled(val) {
19 this.toggleAttribute('disabled', val);
20 }
21}
Defining Elements

Register a custom element with the customElements.define() method. The first argument is the element name, the second is the class constructor, and an optional third argument specifies which built-in element to extend.

Element naming follows strict rules: the name must contain at least one hyphen (-), cannot start with a digit, cannot be a single character, and must not match any reserved names. The hyphen requirement prevents conflicts with current and future HTML elements.

defining.js
JavaScript
1// Valid custom element names
2customElements.define('my-button', MyButton);
3customElements.define('app-header', AppHeader);
4customElements.define('data-table', DataTable);
5customElements.define('ui-calendar', UICalendar);
6
7// Invalid names — will throw
8customElements.define('myelement', E); // No hyphen
9customElements.define('my-Element', E); // Uppercase not allowed
10customElements.define('1my-el', E); // Starts with digit
11customElements.define('font-color', E); // Reserved "font-" prefix

danger

Custom element names are case-sensitive and must be lowercase. The name must contain a hyphen. Using reserved prefixes like font-, aria-, or data- will throw a SyntaxError.

Extending Built-in Elements

You can extend existing HTML elements by inheriting from their specific interface (e.g., HTMLButtonElement) and passing the extends: "button" option to define(). This preserves built-in accessibility, form association, and keyboard behavior.

extends.js
JavaScript
1class FancyButton extends HTMLButtonElement {
2 constructor() {
3 super();
4 this.style.background = 'linear-gradient(135deg, #667eea, #764ba2)';
5 this.style.color = '#fff';
6 this.style.border = 'none';
7 this.style.padding = '12px 24px';
8 this.style.borderRadius = '6px';
9 this.style.cursor = 'pointer';
10 }
11}
12
13// Register with { extends: 'button' }
14customElements.define('fancy-button', FancyButton, { extends: 'button' });

In HTML, you use the is attribute to apply the custom behavior to the built-in element:

using-extends.html
HTML
1<!-- Using the extended button -->
2<button is="fancy-button">Click Me</button>
3<button is="fancy-button" disabled>Disabled</button>
4
5<!-- The is attribute tells the browser to upgrade
6 the native button to your custom class -->
Element Upgrades

Custom Elements support a two-phase lifecycle: elements can exist in the DOM before their definition is registered. They start as HTMLUnknownElement instances and are upgraded when customElements.define() is called. The :defined CSS pseudo-class and the customElements.whenDefined() method help manage this transition.

upgrades.html
HTML
1<style>
2 my-counter:not(:defined) {
3 display: none;
4 }
5 my-counter:defined {
6 display: inline-flex;
7 }
8</style>
9
10<!-- This element exists before its definition -->
11<my-counter></my-counter>
12
13<script>
14 // Load the definition asynchronously
15 import('./my-counter.js').then(() => {
16 // All <my-counter> elements are now upgraded
17 });
18
19 // Or wait for a specific element
20 customElements.whenDefined('my-counter')
21 .then(() => {
22 console.log('my-counter is now defined');
23 });
24</script>

best practice

Use the :defined pseudo-class to hide custom elements before they are upgraded. This prevents Flash of Unstyled Custom Element (FOUCE). The customElements.whenDefined() method returns a promise that resolves when the element is registered.
Rendering

Custom elements can render content using innerHTML directly on the element or, more commonly, through a shadow DOM for encapsulation. Using <template> elements for markup definition is the preferred approach as it avoids repeated string parsing and improves performance.

rendering.js
JavaScript
1class CardElement extends HTMLElement {
2 constructor() {
3 super();
4 this.attachShadow({ mode: 'open' });
5 }
6
7 connectedCallback() {
8 // Create template from a <template> element
9 const tmpl = document.getElementById('card-tmpl');
10 const clone = tmpl.content.cloneNode(true);
11
12 // Populate dynamic content
13 clone.querySelector('.title').textContent =
14 this.getAttribute('title') || 'Untitled';
15 clone.querySelector('.desc').textContent =
16 this.getAttribute('description') || '';
17
18 this.shadowRoot.appendChild(clone);
19 }
20}
21
22customElements.define('card-el', CardElement);
card-template.html
HTML
1<!-- Template definition -->
2<template id="card-tmpl">
3 <style>
4 .card {
5 border: 1px solid #e0e0e0;
6 border-radius: 8px;
7 padding: 16px;
8 font-family: system-ui;
9 }
10 .title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
11 .desc { color: #666; font-size: 14px; }
12 </style>
13 <div class="card">
14 <div class="title"></div>
15 <div class="desc"></div>
16 </div>
17</template>
18
19<!-- Usage -->
20<card-el title="Hello" description="World"></card-el>
Best Practices

Constructor Limitations

Call super() before anything else — the element is not fully initialized until super() returns
Do not read or write attributes in the constructor — they may not be set yet
Do not inspect child elements or the parent node — the element is not yet connected
Do not use document.createElement or document.write inside the constructor
Setting innerHTML on the element itself (not shadowRoot) is allowed but discouraged
Create shadow DOM in the constructor if the element uses it (attachShadow)

Attribute Reflection

Properties and attributes should stay synchronized. When a property is set via JavaScript, reflect the change to the attribute. When an attribute changes via HTML, update the property. This dual-surface API ensures the element works consistently in both declarative (HTML) and imperative (JS) usage.

reflection.js
JavaScript
1class ProgressBar extends HTMLElement {
2 static get observedAttributes() {
3 return ['value', 'max'];
4 }
5
6 get value() {
7 return parseFloat(this.getAttribute('value')) || 0;
8 }
9
10 set value(val) {
11 this.setAttribute('value', val);
12 }
13
14 get max() {
15 return parseFloat(this.getAttribute('max')) || 100;
16 }
17
18 set max(val) {
19 this.setAttribute('max', val);
20 }
21
22 attributeChangedCallback(name, oldVal, newVal) {
23 if (oldVal === newVal) return;
24 this._updateProgress();
25 }
26
27 _updateProgress() {
28 const pct = (this.value / this.max) * 100;
29 if (this.shadowRoot) {
30 this.shadowRoot.querySelector('.fill')
31 .style.width = pct + '%';
32 }
33 }
34}

Web Component Checklist

Always use shadow DOM for style and DOM encapsulation
Use HTML templates instead of string concatenation for markup
Custom element names must contain a hyphen and be lowercase
Observe only the attributes your element actually reacts to
Clean up all resources in disconnectedCallback to avoid memory leaks
Guard attributeChangedCallback with an oldVal !== newVal check
Use the :defined pseudo-class to prevent FOUCE during upgrades
Provide both property and attribute access for configuration
Avoid making API calls in the constructor — defer to connectedCallback
Test elements in isolation and across different frameworks

Live preview of a working custom counter element:

preview
Use Cases

Custom Elements shine in several key scenarios where reusable, self-contained components are needed without framework overhead.

Widgets and Embeds

Third-party widgets, analytics dashboards, social media embeds, and interactive charts benefit from custom elements because they encapsulate all CSS and DOM, preventing conflicts with the host page. A single <chart-widget> tag replaces dozens of configuration options and nested divs.

Design System Components

Design systems use custom elements to distribute components across multiple frameworks (React, Vue, Angular) with a single implementation. The custom element provides a framework-agnostic boundary — it works in any tech stack because the browser handles the rendering lifecycle.

design-system.html
HTML
1<!-- Same component, different frameworks -->
2<ds-button variant="primary" size="lg">Submit</ds-button>
3<ds-card>
4 <h3 slot="title">Card Title</h3>
5 <p>This card is a custom element used across all apps.</p>
6</ds-card>
7<ds-modal open>
8 <ds-modal-header slot="header">Confirm</ds-modal-header>
9 <ds-modal-body>Are you sure?</ds-modal-body>
10</ds-modal>
🔥

pro tip

Custom Elements integrate seamlessly with React (via ref and event wiring), Vue (via v-bind for attributes), and Angular (with CUSTOM_ELEMENTS_SCHEMA). They are the only truly framework-agnostic component model.
📝

note

Remember: Custom Elements are part of the broader Web Components spec alongside Shadow DOM and HTML Templates. Master all three for full encapsulation power. See the Shadow DOM and Template & Slot pages for the complete picture.
$Blueprint — Engineering Documentation·Section ID: HTML-25·Revision: 1.0