Custom Elements
Custom Elements are a cornerstone of the Web Components specification, enabling developers to define their own HTML elements with encapsulated behavior and styling. They bridge the gap between standard HTML and reusable component-based architecture, allowing you to create rich, interactive elements that work seamlessly across frameworks.
A custom element is essentially a JavaScript class that extends HTMLElement (or a specialized interface like HTMLButtonElement). The browser's Custom Elements API handles registration, lifecycle management, and attribute observation automatically.
info
Custom elements have a well-defined lifecycle with five distinct callback methods. The browser invokes these callbacks automatically as the element is created, added, moved, or removed from the DOM.
constructor
Called when the element is created. Use it to set up initial state, create shadow DOM, and attach event listeners. You must call super() first and avoid reading attributes or inspecting the DOM at this stage.
| 1 | class MyElement extends HTMLElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | this._count = 0; |
| 5 | this.attachShadow({ mode: 'open' }); |
| 6 | this.shadowRoot.innerHTML = ` |
| 7 | <button id="btn">Click me</button> |
| 8 | `; |
| 9 | } |
| 10 | } |
connectedCallback
Invoked when the element is inserted into the DOM. This is the correct place to perform rendering, fetch resources, add document-level event listeners, and set up observers like IntersectionObserver.
| 1 | connectedCallback() { |
| 2 | this._render(); |
| 3 | this._observer = new IntersectionObserver( |
| 4 | (entries) => { |
| 5 | if (entries[0].isIntersecting) { |
| 6 | this.setAttribute('visible', ''); |
| 7 | } |
| 8 | } |
| 9 | ); |
| 10 | this._observer.observe(this); |
| 11 | } |
| 12 | |
| 13 | disconnectedCallback() { |
| 14 | this._observer?.disconnect(); |
| 15 | this._observer = null; |
| 16 | } |
disconnectedCallback
Called when the element is removed from the DOM. Clean up all resources: remove event listeners, disconnect observers, cancel timers, and release references to prevent memory leaks.
| 1 | disconnectedCallback() { |
| 2 | this._resizeObserver?.disconnect(); |
| 3 | this._timer && clearInterval(this._timer); |
| 4 | this._abortController?.abort(); |
| 5 | // Remove global listeners added in connectedCallback |
| 6 | document.removeEventListener('scroll', this._boundHandler); |
| 7 | } |
adoptedCallback
Fired when the element is moved into a new document (e.g., when using document.adoptNode). Use this to reinitialize document-specific state like event listeners tied to the document object.
| 1 | adoptedCallback() { |
| 2 | // Re-attach document-level listeners |
| 3 | document.addEventListener('keydown', this._handleKeydown); |
| 4 | } |
attributeChangedCallback
Called whenever an observed attribute changes value. It receives the attribute name, the old value, and the new value. This is the primary mechanism for reacting to external configuration changes.
| 1 | static get observedAttributes() { |
| 2 | return ['disabled', 'value', 'placeholder']; |
| 3 | } |
| 4 | |
| 5 | attributeChangedCallback(name, oldVal, newVal) { |
| 6 | if (oldVal === newVal) return; |
| 7 | switch (name) { |
| 8 | case 'disabled': |
| 9 | this._updateDisabled(newVal !== null); |
| 10 | break; |
| 11 | case 'value': |
| 12 | this._updateValue(newVal); |
| 13 | break; |
| 14 | case 'placeholder': |
| 15 | this._updatePlaceholder(newVal); |
| 16 | break; |
| 17 | } |
| 18 | } |
warning
The static observedAttributes getter returns an array of attribute names that the custom element should monitor. When any of these attributes change value, the attributeChangedCallback is invoked. This is the declarative way to expose element configuration through HTML attributes.
Boolean attributes (like disabled) are considered present when they exist on the element, regardless of their value. Reflecting properties to attributes keeps the DOM in sync with the JavaScript state.
| 1 | class ToggleSwitch extends HTMLElement { |
| 2 | static get observedAttributes() { |
| 3 | return ['checked', 'disabled', 'label']; |
| 4 | } |
| 5 | |
| 6 | get checked() { |
| 7 | return this.hasAttribute('checked'); |
| 8 | } |
| 9 | |
| 10 | set checked(val) { |
| 11 | this.toggleAttribute('checked', val); |
| 12 | } |
| 13 | |
| 14 | get disabled() { |
| 15 | return this.hasAttribute('disabled'); |
| 16 | } |
| 17 | |
| 18 | set disabled(val) { |
| 19 | this.toggleAttribute('disabled', val); |
| 20 | } |
| 21 | } |
Register a custom element with the customElements.define() method. The first argument is the element name, the second is the class constructor, and an optional third argument specifies which built-in element to extend.
Element naming follows strict rules: the name must contain at least one hyphen (-), cannot start with a digit, cannot be a single character, and must not match any reserved names. The hyphen requirement prevents conflicts with current and future HTML elements.
| 1 | // Valid custom element names |
| 2 | customElements.define('my-button', MyButton); |
| 3 | customElements.define('app-header', AppHeader); |
| 4 | customElements.define('data-table', DataTable); |
| 5 | customElements.define('ui-calendar', UICalendar); |
| 6 | |
| 7 | // Invalid names — will throw |
| 8 | customElements.define('myelement', E); // No hyphen |
| 9 | customElements.define('my-Element', E); // Uppercase not allowed |
| 10 | customElements.define('1my-el', E); // Starts with digit |
| 11 | customElements.define('font-color', E); // Reserved "font-" prefix |
danger
Extending Built-in Elements
You can extend existing HTML elements by inheriting from their specific interface (e.g., HTMLButtonElement) and passing the extends: "button" option to define(). This preserves built-in accessibility, form association, and keyboard behavior.
| 1 | class FancyButton extends HTMLButtonElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | this.style.background = 'linear-gradient(135deg, #667eea, #764ba2)'; |
| 5 | this.style.color = '#fff'; |
| 6 | this.style.border = 'none'; |
| 7 | this.style.padding = '12px 24px'; |
| 8 | this.style.borderRadius = '6px'; |
| 9 | this.style.cursor = 'pointer'; |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | // Register with { extends: 'button' } |
| 14 | customElements.define('fancy-button', FancyButton, { extends: 'button' }); |
In HTML, you use the is attribute to apply the custom behavior to the built-in element:
| 1 | <!-- Using the extended button --> |
| 2 | <button is="fancy-button">Click Me</button> |
| 3 | <button is="fancy-button" disabled>Disabled</button> |
| 4 | |
| 5 | <!-- The is attribute tells the browser to upgrade |
| 6 | the native button to your custom class --> |
Custom Elements support a two-phase lifecycle: elements can exist in the DOM before their definition is registered. They start as HTMLUnknownElement instances and are upgraded when customElements.define() is called. The :defined CSS pseudo-class and the customElements.whenDefined() method help manage this transition.
| 1 | <style> |
| 2 | my-counter:not(:defined) { |
| 3 | display: none; |
| 4 | } |
| 5 | my-counter:defined { |
| 6 | display: inline-flex; |
| 7 | } |
| 8 | </style> |
| 9 | |
| 10 | <!-- This element exists before its definition --> |
| 11 | <my-counter></my-counter> |
| 12 | |
| 13 | <script> |
| 14 | // Load the definition asynchronously |
| 15 | import('./my-counter.js').then(() => { |
| 16 | // All <my-counter> elements are now upgraded |
| 17 | }); |
| 18 | |
| 19 | // Or wait for a specific element |
| 20 | customElements.whenDefined('my-counter') |
| 21 | .then(() => { |
| 22 | console.log('my-counter is now defined'); |
| 23 | }); |
| 24 | </script> |
best practice
Custom elements can render content using innerHTML directly on the element or, more commonly, through a shadow DOM for encapsulation. Using <template> elements for markup definition is the preferred approach as it avoids repeated string parsing and improves performance.
| 1 | class CardElement extends HTMLElement { |
| 2 | constructor() { |
| 3 | super(); |
| 4 | this.attachShadow({ mode: 'open' }); |
| 5 | } |
| 6 | |
| 7 | connectedCallback() { |
| 8 | // Create template from a <template> element |
| 9 | const tmpl = document.getElementById('card-tmpl'); |
| 10 | const clone = tmpl.content.cloneNode(true); |
| 11 | |
| 12 | // Populate dynamic content |
| 13 | clone.querySelector('.title').textContent = |
| 14 | this.getAttribute('title') || 'Untitled'; |
| 15 | clone.querySelector('.desc').textContent = |
| 16 | this.getAttribute('description') || ''; |
| 17 | |
| 18 | this.shadowRoot.appendChild(clone); |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | customElements.define('card-el', CardElement); |
| 1 | <!-- Template definition --> |
| 2 | <template id="card-tmpl"> |
| 3 | <style> |
| 4 | .card { |
| 5 | border: 1px solid #e0e0e0; |
| 6 | border-radius: 8px; |
| 7 | padding: 16px; |
| 8 | font-family: system-ui; |
| 9 | } |
| 10 | .title { font-size: 18px; font-weight: 600; margin-bottom: 8px; } |
| 11 | .desc { color: #666; font-size: 14px; } |
| 12 | </style> |
| 13 | <div class="card"> |
| 14 | <div class="title"></div> |
| 15 | <div class="desc"></div> |
| 16 | </div> |
| 17 | </template> |
| 18 | |
| 19 | <!-- Usage --> |
| 20 | <card-el title="Hello" description="World"></card-el> |
Constructor Limitations
Attribute Reflection
Properties and attributes should stay synchronized. When a property is set via JavaScript, reflect the change to the attribute. When an attribute changes via HTML, update the property. This dual-surface API ensures the element works consistently in both declarative (HTML) and imperative (JS) usage.
| 1 | class ProgressBar extends HTMLElement { |
| 2 | static get observedAttributes() { |
| 3 | return ['value', 'max']; |
| 4 | } |
| 5 | |
| 6 | get value() { |
| 7 | return parseFloat(this.getAttribute('value')) || 0; |
| 8 | } |
| 9 | |
| 10 | set value(val) { |
| 11 | this.setAttribute('value', val); |
| 12 | } |
| 13 | |
| 14 | get max() { |
| 15 | return parseFloat(this.getAttribute('max')) || 100; |
| 16 | } |
| 17 | |
| 18 | set max(val) { |
| 19 | this.setAttribute('max', val); |
| 20 | } |
| 21 | |
| 22 | attributeChangedCallback(name, oldVal, newVal) { |
| 23 | if (oldVal === newVal) return; |
| 24 | this._updateProgress(); |
| 25 | } |
| 26 | |
| 27 | _updateProgress() { |
| 28 | const pct = (this.value / this.max) * 100; |
| 29 | if (this.shadowRoot) { |
| 30 | this.shadowRoot.querySelector('.fill') |
| 31 | .style.width = pct + '%'; |
| 32 | } |
| 33 | } |
| 34 | } |
Web Component Checklist
Live preview of a working custom counter element:
Custom Elements shine in several key scenarios where reusable, self-contained components are needed without framework overhead.
Widgets and Embeds
Third-party widgets, analytics dashboards, social media embeds, and interactive charts benefit from custom elements because they encapsulate all CSS and DOM, preventing conflicts with the host page. A single <chart-widget> tag replaces dozens of configuration options and nested divs.
Design System Components
Design systems use custom elements to distribute components across multiple frameworks (React, Vue, Angular) with a single implementation. The custom element provides a framework-agnostic boundary — it works in any tech stack because the browser handles the rendering lifecycle.
| 1 | <!-- Same component, different frameworks --> |
| 2 | <ds-button variant="primary" size="lg">Submit</ds-button> |
| 3 | <ds-card> |
| 4 | <h3 slot="title">Card Title</h3> |
| 5 | <p>This card is a custom element used across all apps.</p> |
| 6 | </ds-card> |
| 7 | <ds-modal open> |
| 8 | <ds-modal-header slot="header">Confirm</ds-modal-header> |
| 9 | <ds-modal-body>Are you sure?</ds-modal-body> |
| 10 | </ds-modal> |
pro tip
note