Custom Elements Basics — Complete Guide
In this tutorial, you will learn about Custom Elements Basics. We cover key concepts, practical examples, and best practices to help you master this topic.
Custom Elements let you define new HTML tags with custom behavior using the customElements.define API and HTMLElement class extension.
What You'll Learn
- How to define a custom element with customElements.define
- Custom element naming rules and conventions
- The difference between autonomous and customized built-in elements
- How to upgrade existing elements
Why It Matters
Custom Elements are the foundation of Web Components. Understanding how to define, register, and use custom HTML tags is the first step to creating reusable components without a framework.
Real-World Use
- A
<file-tree>element for file system navigation - A
<color-picker>element that replaces a standard input - A
<google-map>element that wraps the Maps API
flowchart LR A[Class extends HTMLElement] --> B[Define constructor] B --> C[Add methods/properties] C --> D[customElements.define] D --> E[Registered element] E --> F[Use in HTML] F --> G[Browser upgrades element]
Defining a Custom Element
Every custom element extends HTMLElement and must be registered with a hyphenated name.
// Define the element class
class HelloWorld extends HTMLElement {
constructor() {
super();
// Element initialization
console.log('HelloWorld element created');
}
// Called when element is added to DOM
connectedCallback() {
this.textContent = 'Hello, Web Components!';
}
}
// Register the element (must have a hyphen)
customElements.define('hello-world', HelloWorld);
// Now use in HTML:
// <hello-world></hello-world>
// Renders: Hello, Web Components!
console.log('Custom element defined: hello-world');
// Naming rules:
// 1. Must contain a hyphen (kebab-case)
// 2. Cannot start with a digit
// 3. Cannot be a reserved name (annotation-xml, color-profile, font-face, etc.)
// 4. Use lowercase; no uppercase characters
Expected output: Placing <hello-world> in HTML renders the greeting. The browser calls the constructor once and connectedCallback when the element is inserted.
The Constructor
The constructor is where you set up initial state, but there are restrictions.
class ProperElement extends HTMLElement {
constructor() {
// MUST call super() first
super();
// This is safe: initializing state
this._count = 0;
this._shadow = null;
// This is safe: attaching shadow DOM
this.attachShadow({ mode: 'open' });
// This is NOT safe: accessing attributes or children
// The element is not yet connected to the DOM
// console.log(this.getAttribute('data-value')); // May be empty
console.log('Constructor completed');
}
connectedCallback() {
// Safe to access attributes, children, and DOM here
const initialValue = this.getAttribute('value');
console.log('Initial value:', initialValue);
this.render();
}
render() {
this.shadowRoot.innerHTML = `<p>Count: ${this._count}</p>`;
}
}
customElements.define('proper-element', ProperElement);
Expected output: The constructor runs first, setting up state and Shadow Dom. When connected to the DOM, connectedCallback accesses attributes and renders.
connectedCallback and disconnectedCallback
These lifecycle callbacks handle setup and teardown.
class LifecycleDemo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
console.log('1. Constructor');
}
connectedCallback() {
console.log('2. Connected to DOM');
this.shadowRoot.innerHTML = `
<p>Lifecycle Demo Element</p>
<button id="action">Click</button>
`;
// Set up event listeners
this._clickHandler = () => console.log('Button clicked');
this.shadowRoot.getElementById('action')
.addEventListener('click', this._clickHandler);
// Start interval
this._interval = setInterval(() => {
console.log('Interval tick');
}, 3000);
}
disconnectedCallback() {
console.log('3. Disconnected from DOM');
// Clean up: remove listeners, stop timers
this.shadowRoot.getElementById('action')
.removeEventListener('click', this._clickHandler);
clearInterval(this._interval);
console.log('Cleanup complete');
}
}
customElements.define('lifecycle-demo', LifecycleDemo);
Expected output: Adding the element to the page logs construction and connection. Clicking the button logs clicks. The interval ticks every 3 seconds. Removing the element logs disconnection and cleanup.
Custom Element Methods and Properties
Custom elements can have their own methods and properties, just like regular classes.
class Counter extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._value = 0;
}
connectedCallback() {
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
.counter { display: flex; align-items: center; gap: 8px; font-family: sans-serif; }
button { padding: 4px 12px; cursor: pointer; }
.value { font-size: 1.5em; font-weight: bold; min-width: 2em; text-align: center; }
</style>
<div class="counter">
<button id="decr">-</button>
<span class="value" id="value">${this._value}</span>
<button id="incr">+</button>
</div>
`;
this.shadowRoot.getElementById('incr')
.addEventListener('click', () => this.increment());
this.shadowRoot.getElementById('decr')
.addEventListener('click', () => this.decrement());
}
// Public API
increment() {
this._value++;
this.shadowRoot.getElementById('value').textContent = this._value;
this.dispatchEvent(new CustomEvent('counter-change', {
detail: { value: this._value }
}));
}
decrement() {
this._value--;
this.shadowRoot.getElementById('value').textContent = this._value;
this.dispatchEvent(new CustomEvent('counter-change', {
detail: { value: this._value }
}));
}
get value() {
return this._value;
}
set value(val) {
this._value = val;
if (this.shadowRoot) {
this.shadowRoot.getElementById('value').textContent = val;
}
}
}
customElements.define('my-counter', Counter);
// Usage:
// const counter = document.querySelector('my-counter');
// counter.increment();
// counter.value = 10;
// console.log(counter.value);
Expected output: The counter renders with buttons and a value display. Clicking buttons changes the value. The public API allows programmatic access and control from JavaScript.
Customized Built-in Elements
Extend existing HTML elements instead of creating from scratch.
// Extend a built-in element (must use specific class)
class FancyButton extends HTMLButtonElement {
constructor() {
super();
console.log('Fancy button created');
}
connectedCallback() {
this.classList.add('fancy-btn');
this.style.padding = '10px 20px';
this.style.background = 'linear-gradient(45deg, #667eea, #764ba2)';
this.style.color = 'white';
this.style.border = 'none';
this.style.borderRadius = '4px';
this.style.cursor = 'pointer';
this.style.fontSize = '16px';
// Add ripple effect on click
this.addEventListener('click', this._createRipple);
}
_createRipple(e) {
const ripple = document.createElement('span');
const rect = this.getBoundingClientRect();
ripple.style.position = 'absolute';
ripple.style.width = '20px';
ripple.style.height = '20px';
ripple.style.background = 'rgba(255,255,255,0.5)';
ripple.style.borderRadius = '50%';
ripple.style.left = `${e.clientX - rect.left - 10}px`;
ripple.style.top = `${e.clientY - rect.top - 10}px`;
ripple.style.transform = 'scale(0)';
ripple.style.transition = 'all 0.5s ease-out';
this.appendChild(ripple);
requestAnimationFrame(() => {
ripple.style.transform = 'scale(3)';
ripple.style.opacity = '0';
});
setTimeout(() => ripple.remove(), 500);
}
}
// Register as customized built-in
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// Usage:
// <button is="fancy-button">Click Me</button>
// This keeps all native button behavior (form submission, focus, accessibility)
Expected output: The button inherits all native button functionality while adding custom styling and a ripple effect. The is attribute applies the extension.
Element Upgrade
Custom elements can be defined after they appear in the HTML. The browser upgrades them automatically.
// In HTML, before JavaScript loads:
// <late-element>This will be upgraded</late-element>
// The element exists as HTMLUnknownElement initially
class LateElement extends HTMLElement {
connectedCallback() {
this.textContent = 'Upgraded! The element is now alive.';
this.style.color = 'green';
this.style.fontWeight = 'bold';
}
}
// Define after the element appears in DOM
setTimeout(() => {
console.log('Defining late element...');
customElements.define('late-element', LateElement);
console.log('Element upgraded');
// The browser automatically calls connectedCallback
// on existing <late-element> instances
}, 2000);
// You can also check if an element is defined:
console.log('Is defined?', customElements.get('late-element'));
// Wait for definition:
window.customElements.whenDefined('late-element')
.then(() => console.log('late-element is now defined'));
Expected output: Initially, the element shows its raw text content. After 2 seconds, it upgrades and displays "Upgraded!" in green. The whenDefined promise resolves.
Common Mistakes
- Not calling super() in the constructor — This throws a ReferenceError. Always call
super()as the first line in the constructor. - Using a non-hyphenated name — Custom element names must include a hyphen.
myelementis invalid;my-elementis valid. - Defining the same element twice — Calling
customElements.definewith an already-registered name throws a DOMException. - Accessing attributes or children in the constructor — The element is not yet connected. Use
connectedCallbackfor DOM access. - Forgetting the
extendsoption for customized built-ins — Without{ extends: 'button' }, the extended class does not work correctly.
Practice Questions
- What is the naming rule for custom elements? Must contain a hyphen (kebab-case), cannot start with a digit, must be lowercase.
- What is the difference between autonomous and customized built-in elements? Autonomous extends HTMLElement directly (must have closing tag). Customized built-in extends a specific HTML element like HTMLButtonElement (uses
isattribute). - Why should you avoid DOM access in the constructor? The element is not yet connected to the DOM. Attributes, children, and parentNode are not available until connectedCallback.
- Challenge: Create a custom
<progress-bar>element that accepts avalueattribute (0-100) and displays a colored progress bar. Update the bar when the attribute changes.
FAQ
Mini Project
Build a custom <rating-stars> element that displays 1-5 star ratings. Accept a value attribute for the current rating. Allow users to click a star to set the rating. Dispatch a rating-change event when the rating changes. Style the stars with CSS (filled vs empty). Make stars keyboard-accessible with Tab and Arrow keys.
What's Next
Continue with Lesson 3: Custom Element Lifecycle to learn all lifecycle callbacks in depth.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro