Custom Elements Built-In — Complete Guide
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
- Using autonomous elements when built-in extension is better — Autonomous elements lose native behavior. Use extends for buttons, inputs, and selects.
- Forgetting the extends option in customElements.define — Without { extends: 'button' }, the browser treats it as an autonomous element.
- Not cleaning up custom DOM added in the shadow — Built-in elements cannot have Shadow Dom. Manage appended elements in connectedCallback/disconnectedCallback.
- Expecting extends to work in Safari — Safari does not support customized built-in elements. Use autonomous elements if Safari support is required.
- Overriding native methods incorrectly — Calling super methods is essential. Override addEventListener carefully or use connectedCallback instead.
Practice Questions
- What option is required in customElements.define for built-in extensions? { extends: 'tagname' } where tagname is 'button', 'input', etc.
- What HTML attribute is used to apply a customized built-in element? The
isattribute:<button is="my-button">. - Why extend built-in elements instead of creating autonomous ones? To inherit native behavior: form participation, keyboard handling, accessibility, and semantic meaning.
- 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
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