Skip to content

Polymer Custom Elements — Creating Reusable Custom HTML Elements

DodaTech Updated 2026-06-28 5 min read

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

Custom Elements are the core of Web Components — they let you define new HTML elements with custom behavior, lifecycle hooks, and reactive attribute handling using the standard CustomElementRegistry API.

What You'll Learn

  • Defining custom elements with customElements.define
  • Lifecycle callbacks (connected, disconnected, attributeChanged)
  • Observed attributes and reactions
  • Element properties and methods
  • Extending native HTML elements

Why It Matters

Custom Elements let you create your own HTML vocabulary. Instead of <div class="user-card">, you write <user-card>. The browser handles element registration, lifecycle, and attribute observation natively.

Real-World Use

A UI library with <custom-button>, <custom-dialog>, <custom-tabs>, and <custom-table> elements that encapsulate behavior and styling, used across multiple applications.

Custom Element Architecture

flowchart TD
    A[Custom Element] --> B[Definition]
    A --> C[Lifecycle]
    A --> D[Attributes]
    A --> E[Properties]
    B --> F[Class]
    B --> G[Registration]
    C --> H[constructor]
    C --> I[connectedCallback]
    C --> J[disconnectedCallback]
    C --> K[attributeChangedCallback]
    D --> L[observedAttributes]
    D --> M[Reaction]
    E --> N[Getters/Setters]
    style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px

Basic Custom Element

class UserCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          border: 1px solid #e0e0e0;
          border-radius: 8px;
          padding: 16px;
          display: flex;
          align-items: center;
          gap: 12px;
        }
        .avatar {
          width: 50px; height: 50px; border-radius: 50%;
          background: #1a237e; color: white;
          display: flex; align-items: center; justify-content: center;
          font-size: 20px; font-weight: bold;
        }
        .name { font-size: 16px; font-weight: bold; }
        .email { font-size: 14px; color: #666; }
      </style>
      <div class="card">
        <div class="avatar">${this.getAttribute('name')?.charAt(0) || '?'}</div>
        <div>
          <div class="name">${this.getAttribute('name') || 'Unknown'}</div>
          <div class="email">${this.getAttribute('email') || ''}</div>
        </div>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);
<user-card name="Alice Johnson" email="alice@example.com"></user-card>

Expected output: A user card with avatar initial, name, and email. The element registers as <user-card> and initializes on first connection.

Lifecycle Callbacks

class LifecycleElement extends HTMLElement {
  constructor() {
    super();
    this.timer = null;
  }

  static get observedAttributes() {
    return ['status', 'value'];
  }

  connectedCallback() {
    this._render();
    this.timer = setInterval(() => this._tick(), 1000);
  }

  disconnectedCallback() {
    clearInterval(this.timer);
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (this.isConnected) this._render();
  }

  _tick() {
    const val = parseInt(this.getAttribute('value') || '0');
    this.setAttribute('value', String(val + 1));
  }

  _render() {
    if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <p>Status: ${this.getAttribute('status') || 'unknown'}</p>
      <p>Value: ${this.getAttribute('value') || '0'}</p>
    `;
  }
}

customElements.define('lifecycle-el', LifecycleElement);

Expected output: The element logs lifecycle events. Attributes trigger attributeChangedCallback. A timer increments value every second.

Observed Attributes

class ProgressBar extends HTMLElement {
  static get observedAttributes() {
    return ['value', 'max', 'color'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() { this._render(); }
  attributeChangedCallback() { this._render(); }

  _render() {
    const value = parseFloat(this.getAttribute('value') || '0');
    const max = parseFloat(this.getAttribute('max') || '100');
    const percent = Math.min(100, Math.max(0, (value / max) * 100));
    const color = this.getAttribute('color') || '#4a90d9';

    this.shadowRoot.innerHTML = `
      <div class="progress">
        <div class="bar" style="width:${percent}%"></div>
      </div>
      <div class="label">${Math.round(percent)}%</div>
    `;
  }

  get value() { return parseFloat(this.getAttribute('value') || '0'); }
  set value(val) { this.setAttribute('value', String(val)); }

  get max() { return parseFloat(this.getAttribute('max') || '100'); }
  set max(val) { this.setAttribute('max', String(val)); }
}

customElements.define('progress-bar', ProgressBar);

Expected output: A progress bar updates when value, max, or color attributes change. Property getters/setters provide attribute Reflection.

Element Properties and Methods

class CounterElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._count = 0;
  }

  get count() { return this._count; }

  set count(val) {
    this._count = val;
    this._update();
    this.dispatchEvent(new CustomEvent('count-changed', {
      detail: { count: val }
    }));
  }

  increment() { this.count++; }
  decrement() { this.count--; }
  reset() { this.count = 0; }

  connectedCallback() {
    this._render();
    this._setupListeners();
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('#inc').removeEventListener('click', this._inc);
    this.shadowRoot.querySelector('#dec').removeEventListener('click', this._dec);
  }

  _setupListeners() {
    this._inc = () => this.increment();
    this._dec = () => this.decrement();
    this.shadowRoot.querySelector('#inc').addEventListener('click', this._inc);
    this.shadowRoot.querySelector('#dec').addEventListener('click', this._dec);
  }

  _render() {
    this.shadowRoot.innerHTML = `
      <button id="dec">-</button>
      <span class="count">${this._count}</span>
      <button id="inc">+</button>
      <button id="reset">Reset</button>
    `;
    this.shadowRoot.querySelector('#reset').onclick = () => this.reset();
  }

  _update() {
    const el = this.shadowRoot.querySelector('.count');
    if (el) el.textContent = this._count;
  }
}

customElements.define('counter-el', CounterElement);

Expected output: A counter with increment, decrement, and reset buttons. The count-changed event fires on every change.

Extending Native Elements

class ConfirmButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', this._onClick);
  }

  _onClick(event) {
    if (!this.hasAttribute('no-confirm')) {
      const message = this.getAttribute('confirm-message') || 'Are you sure?';
      if (!confirm(message)) {
        event.preventDefault();
        event.stopPropagation();
      }
    }
  }

  setLoading(loading) {
    this.disabled = loading;
    this.textContent = loading ? 'Loading...' : (this.getAttribute('original-text') || 'Confirm');
  }

  connectedCallback() {
    if (!this.getAttribute('original-text')) {
      this.setAttribute('original-text', this.textContent);
    }
  }
}

customElements.define('confirm-button', ConfirmButton, { extends: 'button' });
<button is="confirm-button" confirm-message="Delete this item?">Delete</button>

Expected output: The confirm-button extends the native button. Clicking shows a confirmation dialog.

Common Mistakes

  1. Forgetting hyphen in element name - Custom element names must contain a hyphen. Single-word names throw an error.

  2. Calling attachShadow multiple times - attachShadow can only be called once. Check for existing shadow root.

  3. Not using static observedAttributes - Without it, attributeChangedCallback never fires.

  4. Setting attributes in constructor - Use connectedCallback for DOM operations.

  5. Not cleaning up event listeners - Remove listeners in disconnectedCallback to prevent memory leaks.

Practice Questions

  1. How do you observe attribute changes on a custom element?
  2. What is the difference between connectedCallback and constructor?
  3. How do you define an element that extends a native HTML element?
  4. How do you reflect a JS property to an HTML attribute?
  5. How do you fire a custom event from within a custom element?

Challenge: Build a custom <star-rating> element with observed attributes for value (0-5) and size, click to set rating, hover preview, keyboard Accessibility, custom event on rating change, and proper cleanup.

FAQ

Can I have multiple custom elements in one file?

Yes. Define multiple classes and register each with its own customElements.define call.

How do custom elements interact with forms?

Use the ElementInternals API for form-associated custom elements. This enables form participation and validation.

Can I use custom elements inside React?

Yes. React supports custom elements. Pass data via attributes (strings) or properties (ref + assignment).

How do I test custom elements?

Use @open-wc/testing or @web/test-runner. Mount the element, set properties, assert DOM state.

What is the difference between autonomous and customized built-in elements?

Autonomous extends HTMLElement. Customized built-in extends specific elements using the 'is' attribute.

Mini Project

Build a form component library with: <form-input> (text input with label, validation, error message), <form-select> (dropdown with options), <form-checkbox> (styled checkbox), and <form-group> (validation summary).

What's Next

Custom Elements define the element. Learn how Polymer Shadow DOM provides DOM and style Encapsulation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro