Custom elements under the hood

Hi there 🫶 I want to share a recap about the inner workings of custom elements that I have put together now that I have been working with them a little bit more.
Understanding the parsing process
I wondered what happened exactly when the browser sees a custom element. Let’s go through the process:
When the DOM parser encounters a custom element tag like <my-component></my-component>
, it goes through several key stages:
1. Tokenization
The HTML parser first tokenizes the input. During this phase:
- It detects a tag named
my-component
- Begins creating an element corresponding to that tag name
- Marks this element as a custom element candidate due to the presence of a dash (
-
) in its name
2. Element Creation
The parser then creates an instance of the element through these steps:
- If the element has been defined (via
customElements.define
), it's instantiated as an instance of its custom class - The constructor is invoked
- Tag attributes are processed and attached to the element
- Any child nodes are parsed and added
- The fully constructed element is inserted into the DOM tree at the parser’s current position
3. Lifecycle Callback
After construction and DOM insertion:
- The
connectedCallback
is triggered - This marks the point where the element is fully integrated into the document
Working with Custom Elements
Now, there are some considerations about writing the custom element class that are not so obvious from the beginning.
Here’s a basic example of defining a custom element:
class MyCustomElement extends HTMLElement {
constructor() {
super();
this.innerHTML = "<div>MyCustomElement</div>";
}
}
customElements.define("my-custom-element", MyCustomElement);j
Important Considerations
When working with custom elements, keep in mind:
- DOM Access: Within the custom element class,
this
represents the node being inserted, giving you access to the DOM. - Constructor Limitations: The constructor runs before the element is attached to the document
- Layout measurements and parent-child relationships may be unreliable at this stage
- Properties likeoffsetWidth
or sibling queries may return inaccurate values - Best Practices:
- Initialize visual state in the constructor
- Leave DOM manipulations for theconnectedCallback
- Consider adding a loading state for heavy processing or async tasks inconnectedCallback
to avoid visual flicker
Here is a cool example that you can play around with to get a better understanding of the component lifecycle
// Define a custom element that demonstrates the full lifecycle
class LifecycleElement extends HTMLElement {
constructor() {
super();
console.log('1. Constructor: Element is being created');
// Create a shadow root for encapsulation
const shadow = this.attachShadow({ mode: 'open' });
// Initialize but don't measure or query siblings yet
this.initialContent = document.createElement('div');
this.initialContent.textContent = 'Initializing...';
shadow.appendChild(this.initialContent);
// Demonstrate why measuring here is premature
console.log('Width in constructor:', this.offsetWidth); // Will likely be 0
}
connectedCallback() {
console.log('2. Connected: Element is now in the DOM');
// Safe to measure and query the DOM now
console.log('Width in connectedCallback:', this.offsetWidth);
// Demonstrate async behavior
this.updateContent();
}
async updateContent() {
// Simulate an async operation
await new Promise(resolve => setTimeout(resolve, 1000));
const content = document.createElement('div');
content.innerHTML = `
<style>
.container {
padding: 20px;
border: 2px solid #333;
margin: 10px;
}
.info {
color: #666;
font-style: italic;
}
</style>
<div class="container">
<h2>Lifecycle Element</h2>
<p>Element width: ${this.offsetWidth}px</p>
<p class="info">This content was rendered asynchronously</p>
</div>
`;
this.shadowRoot.replaceChildren(content);
}
disconnectedCallback() {
console.log('3. Disconnected: Element removed from DOM');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`4. Attribute changed: ${name} from ${oldValue} to ${newValue}`);
}
static get observedAttributes() {
return ['data-state'];
}
}
// Register the custom element
customElements.define('lifecycle-element', LifecycleElement);
// Usage example
const example = `
// Create and append element
const element = document.createElement('lifecycle-element');
document.body.appendChild(element);
// Later: modify attribute
element.setAttribute('data-state', 'updated');
// Later: remove element
element.remove();
`;
And that’s all for now, next chapter: Virtual DOM vs Shadow DOM.
Want to know more about us? Come by www.etereo.io ❤