Skip to content

Custom Elements Built-In — Complete Guide

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Custom Elements Built. We cover key concepts, practical examples, and best practices to help you master this topic.

Customized built-in elements extend native HTML elements like button, input, and select, inheriting all their built-in behavior and Accessibility features.

What You'll Learn

  • How to extend native HTML elements
  • The is attribute syntax for built-in extensions
  • What behavior is inherited from the native element
  • When to extend native elements vs creating autonomous elements
  • Browser compatibility considerations

Why It Matters

Native HTML elements have built-in accessibility, keyboard support, form participation, and semantic meaning. Extending them preserves all of this while adding custom functionality. A custom button is still a button for screen readers and keyboards.

Real-World Use

  • A styled button that maintains form submission capabilities
  • A validated input that shows custom error messages
  • A select with enhanced search functionality
flowchart LR
  A[HTMLButtonElement] --> B[class extends HTMLButtonElement]
  B --> C[customElements.define with extends]
  C --> D[

Extending a Button

Extend HTMLButtonElement to create a custom button with native behavior.

class ConfirmButton extends HTMLButtonElement {
    constructor() {
        super();
        this._originalText = this.textContent;
    }

    connectedCallback() {
        this.addEventListener('click', this._handleClick);
    }

    disconnectedCallback() {
        this.removeEventListener('click', this._handleClick);
    }

    _handleClick(event) {
        const message = this.getAttribute('confirm-message') || 'Are you sure?';
        if (!confirm(message)) {
            event.preventDefault();
            event.stopPropagation();
            console.log('Action cancelled by user');
            return;
        }
        console.log('Action confirmed');

        // Show loading state
        this.textContent = this.getAttribute('loading-text') || 'Processing...';
        this.disabled = true;

        // Reset after timeout (simulating async operation)
        setTimeout(() => {
            this.textContent = this._originalText;
            this.disabled = false;
        }, 2000);
    }

    // Observe the confirm-message attribute
    static get observedAttributes() {
        return ['confirm-message', 'loading-text'];
    }

    attributeChangedCallback(name, oldValue, newValue) {
        // No re-render needed for attribute changes
        console.log('Attribute changed:', name, newValue);
    }
}

customElements.define('confirm-button', ConfirmButton, { extends: 'button' });

// Usage:
// <button is="confirm-button" confirm-message="Delete this item?">Delete</button>
// The button maintains form behavior, focus, and accessibility

Expected output: Clicking the button shows a confirmation dialog. Cancelling prevents the default action. Confirming shows a loading state for 2 seconds. The button remains keyboard-accessible and participates in forms.

Extending an Input

Create a validated input that extends HTMLInputElement.

class ValidatedInput extends HTMLInputElement {
    constructor() {
        super();
        console.log('Validated input created');
    }

    connectedCallback() {
        this.addEventListener('input', this._validate);
        this.addEventListener('blur', this._showError);
    }

    disconnectedCallback() {
        this.removeEventListener('input', this._validate);
        this.removeEventListener('blur', this._showError);
    }

    _validate() {
        const rules = this.getAttribute('rules') || '';
        const errors = [];

        if (rules.includes('required') && !this.value.trim()) {
            errors.push('This field is required');
        }

        if (rules.includes('email') && this.value && !this.value.includes('@')) {
            errors.push('Enter a valid email address');
        }

        if (rules.includes('number') && this.value && isNaN(this.value)) {
            errors.push('Enter a valid number');
        }

        const minLength = parseInt(this.getAttribute('minlength'));
        if (minLength && this.value.length < minLength) {
            errors.push('Minimum ' + minLength + ' characters');
        }

        const maxLength = parseInt(this.getAttribute('maxlength'));
        if (maxLength && this.value.length > maxLength) {
            errors.push('Maximum ' + maxLength + ' characters');
        }

        this._errors = errors;
        this.setCustomValidity(errors.length ? errors[0] : '');

        if (errors.length === 0) {
            this.classList.remove('invalid');
            this.classList.add('valid');
        } else {
            this.classList.add('invalid');
            this.classList.remove('valid');
        }

        this.dispatchEvent(new CustomEvent('validation-change', {
            detail: { valid: errors.length === 0, errors }
        }));
    }

    _showError() {
        const errorContainer = document.getElementById(this.getAttribute('error-for'));
        if (errorContainer) {
            errorContainer.textContent = this._errors ? this._errors.join(', ') : '';
        }
    }
}

customElements.define('validated-input', ValidatedInput, { extends: 'input' });

// Usage:
// <input is="validated-input" type="email" rules="required email" minlength="3" error-for="email-error">
// <div id="email-error" class="error-message"></div>

Expected output: The input validates in real-time as the user types. Invalid state shows CSS classes. The native Constraint Validation API is used via setCustomValidity. Error messages appear on blur.

Extending a Select

Create an enhanced select with search functionality.

class SearchableSelect extends HTMLSelectElement {
    constructor() {
        super();
        this._filtered = false;
    }

    connectedCallback() {
        // Add search input above the select
        this.style.display = 'none';

        this._container = document.createElement('div');
        this._container.className = 'searchable-select';

        this._searchInput = document.createElement('input');
        this._searchInput.type = 'text';
        this._searchInput.placeholder = this.getAttribute('search-placeholder') || 'Search...';
        this._searchInput.className = 'search-input';

        this._listContainer = document.createElement('div');
        this._listContainer.className = 'options-list';

        this._buildOptionList();

        this._container.appendChild(this._searchInput);
        this._container.appendChild(this._listContainer);
        this.parentNode.insertBefore(this._container, this.nextSibling);

        this._searchInput.addEventListener('input', () => this._filterOptions());
        this._listContainer.addEventListener('click', (e) => {
            const item = e.target.closest('.option-item');
            if (item) {
                this.value = item.dataset.value;
                this._updateSelection();
                this.dispatchEvent(new Event('change', { bubbles: true }));
                this._searchInput.value = '';
                this._filterOptions();
            }
        });
    }

    _buildOptionList() {
        this._listContainer.innerHTML = '';
        Array.from(this.options).forEach(opt => {
            if (opt.value) {
                const item = document.createElement('div');
                item.className = 'option-item';
                item.dataset.value = opt.value;
                item.textContent = opt.text;
                if (opt.value === this.value) {
                    item.classList.add('selected');
                }
                this._listContainer.appendChild(item);
            }
        });
    }

    _filterOptions() {
        const query = this._searchInput.value.toLowerCase();
        Array.from(this._listContainer.children).forEach(item => {
            const matches = item.textContent.toLowerCase().includes(query);
            item.style.display = matches ? 'block' : 'none';
        });
    }

    _updateSelection() {
        Array.from(this._listContainer.children).forEach(item => {
            item.classList.toggle('selected', item.dataset.value === this.value);
        });
    }

    disconnectedCallback() {
        if (this._container && this._container.parentNode) {
            this._container.parentNode.removeChild(this._container);
        }
    }
}

customElements.define('searchable-select', SearchableSelect, { extends: 'select' });

// Usage:
// <select is="searchable-select" search-placeholder="Filter options...">
//     <option value="1">Option One</option>
//     <option value="2">Option Two</option>
// </select>

Expected output: The native select is hidden and replaced with a searchable interface. Typing filters options. Selecting an option updates the underlying select value.

Form Participation

Customized built-in elements automatically participate in forms.

// A customized button within a form
// <form id="myForm">
//     <button is="confirm-button" type="submit">Save</button>
// </form>

// The button naturally:
// - Submits the form on click (unless preventDefault)
// - Disables when form is submitting
// - Shows validation errors
// - Works with form.elements

// Check form participation
const form = document.getElementById('myForm');
const customBtn = form.querySelector('[is="confirm-button"]');
console.log('Is in form elements:', Array.from(form.elements).includes(customBtn));
console.log('Type:', customBtn.type);
console.log('Form:', customBtn.form);

// The customized built-in has all native properties:
// customBtn.disabled = true;
// customBtn.name = 'save-btn';
// customBtn.value = 'save';

// For extending input:
const input = document.querySelector('[is="validated-input"]');
console.log('Validity:', input.validity);
console.log('Validation message:', input.validationMessage);
console.log('Will validate:', input.willValidate);

Expected output: Customized built-in elements behave identically to their native counterparts in forms. They appear in form.elements, participate in validation, and submit form data correctly.

Common Mistakes

  1. Using autonomous elements when built-in extension is better — Autonomous elements lose native behavior. Use extends for buttons, inputs, and selects.
  2. Forgetting the extends option in customElements.define — Without { extends: 'button' }, the browser treats it as an autonomous element.
  3. Not cleaning up custom DOM added in the shadow — Built-in elements cannot have Shadow Dom. Manage appended elements in connectedCallback/disconnectedCallback.
  4. Expecting extends to work in Safari — Safari does not support customized built-in elements. Use autonomous elements if Safari support is required.
  5. Overriding native methods incorrectly — Calling super methods is essential. Override addEventListener carefully or use connectedCallback instead.

Practice Questions

  1. What option is required in customElements.define for built-in extensions? { extends: 'tagname' } where tagname is 'button', 'input', etc.
  2. What HTML attribute is used to apply a customized built-in element? The is attribute: <button is="my-button">.
  3. Why extend built-in elements instead of creating autonomous ones? To inherit native behavior: form participation, keyboard handling, accessibility, and semantic meaning.
  4. Challenge: Create a <select is="color-picker"> that shows color swatches next to each option. Selecting a swatch updates both the visual display and the underlying select value.

FAQ

Can customized built-in elements use Shadow DOM?

No. Built-in elements cannot have shadow DOM attached. Use autonomous elements if you need Shadow DOM encapsulation.

Does the is attribute work in all browsers?

Chrome, Firefox, and Edge support it. Safari does not support customized built-in elements. For Safari, use autonomous elements.

Can I extend any HTML element?

Most elements work, but some (like <div>, <span>) have no special behavior to inherit. The most useful extensions are button, input, select, textarea, and a.

Does extending preserve ARIA roles?

Yes. The extended element inherits its implicit ARIA role (button, textbox, listbox, etc.).

Can I extend a customized built-in element?

Yes. You can create a chain of extensions, each adding functionality on top of the previous.

Mini Project

Build a <textarea is="auto-resize-textarea"> that automatically grows in height as the user types. Inherit all native textarea behavior (form submission, scroll, resize handle). Add a character count display below the textarea. The count should update in real-time and show a warning when approaching the maxlength.

What's Next

Continue with Lesson 6: Shadow DOM Basics to learn the fundamentals of Shadow DOM Encapsulation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro