Skip to content

Custom Elements Basics — Complete Guide

DodaTech Updated 2026-06-28 7 min read

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

  1. Not calling super() in the constructor — This throws a ReferenceError. Always call super() as the first line in the constructor.
  2. Using a non-hyphenated name — Custom element names must include a hyphen. myelement is invalid; my-element is valid.
  3. Defining the same element twice — Calling customElements.define with an already-registered name throws a DOMException.
  4. Accessing attributes or children in the constructor — The element is not yet connected. Use connectedCallback for DOM access.
  5. Forgetting the extends option for customized built-ins — Without { extends: 'button' }, the extended class does not work correctly.

Practice Questions

  1. What is the naming rule for custom elements? Must contain a hyphen (kebab-case), cannot start with a digit, must be lowercase.
  2. 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 is attribute).
  3. 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.
  4. Challenge: Create a custom <progress-bar> element that accepts a value attribute (0-100) and displays a colored progress bar. Update the bar when the attribute changes.

FAQ

Can I define a custom element without a hyphen?

No. The HTML specification requires a hyphen in all custom element names to avoid conflicts with current and future native elements.

Can a custom element be self-closing?

No. Autonomous custom elements always need a closing tag: <my-element></my-element>. Only customized built-ins that extend void elements can be self-closing.

What happens if I use an undefined custom element?

The browser renders an HTMLUnknownElement. It has no special behavior, no shadow DOM, and its content is displayed as inline text.

Can I extend a custom element?

Yes. You can extend another custom element by extending its class. The subclass must also be registered with a unique hyphenated name.

How do I check if a custom element is already defined?

Use customElements.get('my-element'). It returns the class constructor if defined, or undefined if not.

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