Web Components and Custom Elements

Reviewed & published by Brayan K

Web Components are a set of browser-native standards — Custom Elements, Shadow DOM, and HTML templates — that let you build reusable, encapsulated UI elements with your own HTML tags that work in any framework or plain HTML page.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: Web Components are browser APIs (customElements, attachShadow), so they shine when run in a real page. For the best experience:

🧩 Real-World Analogy: A Web Component is like a LEGO brick:

Web Components let you create your own HTML elements that bundle structure, style, and behavior into a single reusable tag. Because they are built on browser standards rather than a specific library, a component you write once can be dropped into a React app, a Vue app, a server-rendered page, or a static HTML file without modification. The platform rests on three pillars: Custom Elements, Shadow DOM, and the <template> element.

PillarWhat It ProvidesKey API
Custom ElementsYour own HTML tags with behaviorcustomElements.define()
Shadow DOMEncapsulated, scoped DOM & CSSelement.attachShadow()
TemplatesInert, cloneable markup<template> + cloneNode()

⚠️ What "Run" will and will not show you: the snippets in the next few sections are real browser code, and the editor does have a real DOM — so they define elements successfully, but their visible effect lands in a page you cannot see. You will get the odd console.log line and nothing else. The three exercises further down replace the browser's machinery with a 30-line stand-in that prints every callback as it happens, so the lifecycle stops being something you take on faith.

🔥 Defining a Custom Element

A custom element is a JavaScript class that extends HTMLElement, registered with customElements.define(). The tag name must contain a hyphen so it can never collide with a standard HTML element.

class GreetingBadge extends HTMLElement {
  connectedCallback() {
    this.textContent = "Hello from a custom element!";
  }
}

// Tag name MUST contain a hyphen
customElements.define("greeting-badge", GreetingBadge);

// Now <greeting-badge></greeting-badge> works in HTML
const badge = document.createElement("greeting-badge");
document.body.appendChild(badge);
console.log("Defined:", customElements.get("greeting-badge") !== undefined);

Once defined, the browser upgrades every matching tag — current and future — into an instance of your class.

🔥 Lifecycle Callbacks

Custom elements expose lifecycle callbacks that the browser invokes automatically. The most important are connectedCallback (added to the DOM) and disconnectedCallback (removed). Use the constructor only for setup that does not touch attributes or children.

class TimerLabel extends HTMLElement {
  constructor() {
    super();
    this.seconds = 0;
  }

  connectedCallback() {
    console.log("Element added to the page");
    this.textContent = "Timer: 0s";
    this.interval = setInterval(() => {
      this.seconds++;
      this.textContent = "Timer: " + this.seconds + "s";
    }, 1000);
  }

  disconnectedCallback() {
    console.log("Element removed - cleaning up");
    clearInterval(this.interval);
  }
}

customElements.define("timer-label", TimerLabel);

Cleaning up timers and listeners in disconnectedCallback prevents memory leaks when components are added and removed dynamically.

Worked example: see the callbacks fire, in order

The order of these callbacks is the single most common source of "why is my component empty?" bugs. Reading a list of them does not fix that — watching them fire does. The example below swaps the browser's element machinery for a 30-line stand-in that prints who calls what. The component class itself is written exactly as you would write it for a real page; only the plumbing underneath is fake.

// ---- A stand-in for the browser's custom-element machinery -----------
// Real custom elements need a live page. These few lines reproduce the part
// that actually matters: WHO calls your callbacks, and in WHAT order.

class HTMLElement {                      // the real base class, minus the DOM
  constructor() { this.attributes = {}; }

  setAttribute(name, value) {
    const old = name in this.attributes ? this.attributes[name] : null;
    this.attributes[name] = value;
    // The browser calls attributeChangedCallback ONLY for observed names
    const observed = this.constructor.observedAttributes || [];
    if (observed.includes(name) && this.attributeChangedCallback) {
      this.attributeChangedCallback(name, old, value);
    }
  }

  getAttribute(name) {
    return name in this.attributes ? this.attributes[name] : null;
  }
}

const registry = {};
const customElements = {
  define(tag, cls) {
    if (!tag.includes('-')) {            // the real rule, enforced for real
      throw new Error('Custom element name must contain a hyphen: ' + tag);
    }
    registry[tag] = cls;
  },
  get(tag) { return registry[tag]; },
  create(tag) { return new registry[tag](); }   // = document.createElement(tag)
};

const page = {                           // stands in for document.body
  children: [],
  append(el) {
    this.children.push(el);
    if (el.connectedCallback) el.connectedCallback();
  },
  remove(el) {
    this.children = this.children.filter(c => c !== el);
    if (el.disconnectedCallback) el.disconnectedCallback();
  }
};

// ---- The component: written exactly as you would for a real browser ---
class UserChip extends HTMLElement {
  static get observedAttributes() {
    return ['name', 'status'];           // ONLY these two trigger the callback
  }

  constructor() {
    super();                             // always first - it builds the element
    console.log('1. constructor        (object exists, NOT in the page yet)');
  }

  attributeChangedCallback(attr, oldValue, newValue) {
    console.log('   attributeChanged   ' + attr + ': ' + oldValue + ' -> ' + newValue);
  }

  connectedCallback() {
    console.log('2. connectedCallback  (in the page - safe to read children)');
    this.render();
  }

  disconnectedCallback() {
    console.log('3. disconnected       (clear timers and listeners HERE)');
  }

  render() {
    const name = this.getAttribute('name') || 'Guest';
    const status = this.getAttribute('status') || 'offline';
    console.log('   render -> ' + name + ' (' + status + ')');
  }
}

customElements.define('user-chip', UserChip);
console.log('defined? ' + (customElements.get('user-chip') !== undefined));

// ---- Drive the lifecycle ---------------------------------------------
const chip = customElements.create('user-chip');   // document.createElement
chip.setAttribute('name', 'Ada');
chip.setAttribute('status', 'online');
chip.setAttribute('title', 'tooltip text');        // NOT observed - silence
page.append(chip);                                 // document.body.appendChild
chip.setAttribute('status', 'away');               // a live update
chip.render();
page.remove(chip);                                 // chip.remove()

// ✅ Expected output:
// defined? true
// 1. constructor        (object exists, NOT in the page yet)
//    attributeChanged   name: null -> Ada
//    attributeChanged   status: null -> online
// 2. connectedCallback  (in the page - safe to read children)
//    render -> Ada (online)
//    attributeChanged   status: online -> away
//    render -> Ada (away)
// 3. disconnected       (clear timers and listeners HERE)
//
// Three things this proves:
// - attributeChangedCallback runs BEFORE connectedCallback. That is why the
//   constructor is the wrong place to read attributes: at that moment there
//   are none.
// - Setting 'title' printed nothing. It is not in observedAttributes, so the
//   browser never tells you it changed. This is the bug people hunt for hours.
// - disconnectedCallback is your only chance to stop a timer. Miss it and the
//   element keeps ticking in memory after it has left the page.

🔥 Observing Attributes

To react to attribute changes, declare a static observedAttributes getter listing the attribute names you care about. Only those attributes trigger attributeChangedCallback(name, oldValue, newValue).

class UserChip extends HTMLElement {
  static get observedAttributes() {
    return ["name", "status"];
  }

  attributeChangedCallback(attr, oldValue, newValue) {
    console.log(attr + " changed: " + oldValue + " -> " + newValue);
    this.render();
  }

  connectedCallback() {
    this.render();
  }

  render() {
    const name = this.getAttribute("name") || "Guest";
    const status = this.getAttribute("status") || "offline";
    this.textContent = name + " (" + status + ")";
  }
}

customElements.define("user-chip", UserChip);

const chip = document.createElement("user-chip");
chip.setAttribute("name", "Ada");
chip.setAttribute("status", "online");
document.body.appendChild(chip);

This keeps your component's rendering in sync with its attributes, the same way native elements respond to attributes like disabled or value.

🎯 Your turn: a reactive battery indicator

Build a <battery-level level="80"> element that redraws itself whenever level changes. The harness and the driving code are written for you. Two blanks: the attribute you are opting into, and the bar itself.

// 🎯 YOUR TURN — fill in the blanks marked with ___

// ---- The stand-in harness (same 30 lines as the worked example) ------
class HTMLElement {
  constructor() { this.attributes = {}; }
  setAttribute(name, value) {
    const old = name in this.attributes ? this.attributes[name] : null;
    this.attributes[name] = value;
    const observed = this.constructor.observedAttributes || [];
    if (observed.includes(name) && this.attributeChangedCallback) {
      this.attributeChangedCallback(name, old, value);
    }
  }
  getAttribute(name) { return name in this.attributes ? this.attributes[name] : null; }
}
const registry = {};
const customElements = {
  define(tag, cls) {
    if (!tag.includes('-')) throw new Error('Custom element name must contain a hyphen: ' + tag);
    registry[tag] = cls;
  },
  get(tag) { return registry[tag]; },
  create(tag) { return new registry[tag](); }
};
const page = {
  children: [],
  append(el) { this.children.push(el); if (el.connectedCallback) el.connectedCallback(); },
  remove(el) { this.children = this.children.filter(c => c !== el); if (el.disconnectedCallback) el.disconnectedCallback(); }
};

// ---- Your component --------------------------------------------------
class BatteryLevel extends HTMLElement {
  // 1) Opt the 'level' attribute in, or attributeChangedCallback stays silent
  static get observedAttributes() {
    return ___;                 // 👉 replace ___ with  ['level']
  }

  attributeChangedCallback(attr, oldValue, newValue) {
    console.log('   ' + attr + ': ' + oldValue + ' -> ' + newValue);
    this.render();              // an attribute changed, so redraw
  }

  connectedCallback() {
    console.log('connected');
    this.render();              // draw once on entry, in case nothing changes
  }

  render() {
    const level = Number(this.getAttribute('level') || 0);
    const filled = Math.round(level / 20);   // 0-100 becomes 0-5 blocks

    // 2) Draw 'filled' solid blocks, then empty ones up to 5 in total
    console.log('   [' + ___ + '] ' + level + '%');
    // 👉 replace ___ with   '█'.repeat(filled) + '░'.repeat(5 - filled)
  }
}

customElements.define('battery-level', BatteryLevel);

const battery = customElements.create('battery-level');
battery.setAttribute('level', '100');     // set before it enters the page
page.append(battery);
battery.setAttribute('level', '40');      // a live update
battery.setAttribute('charging', 'yes');  // not observed - nothing happens
battery.setAttribute('level', '0');

// ✅ Expected output once both blanks are filled:
//    level: null -> 100
//    [█████] 100%
// connected
//    [█████] 100%
//    level: 100 -> 40
//    [██░░░] 40%
//    level: 40 -> 0
//    [░░░░░] 0%
//
// No output at all except 'connected'? Blank 1 is not returning an array
// containing 'level', so the browser never reports the change.
// Note the bar is drawn twice at the start: once by the attribute, once by
// connectedCallback. Real components de-duplicate that; yours does not need to.

🔥 Shadow DOM: Encapsulation

attachShadow() attaches a shadow root to your element — a separate DOM subtree whose markup and styles are isolated from the rest of the page. Outside CSS cannot reach in, and your component's styles cannot leak out.

class FancyCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        /* These styles are scoped - they cannot leak out */
        .box { padding: 12px; border: 2px solid teal; border-radius: 8px; }
        p { color: teal; font-weight: bold; }
      </style>
      <div class="box">
        <p>I am encapsulated!</p>
      </div>
    `;
  }
}

customElements.define("fancy-card", FancyCard);
console.log("Shadow root mode:", document.createElement("fancy-card").shadowRoot.mode);

With mode: "open" the shadow root is reachable via element.shadowRoot; with mode: "closed" it is hidden from outside scripts. Encapsulation is what makes components safe to reuse anywhere without CSS conflicts.

🔥 Templates and Slots

The <template> element holds markup that is parsed but inert — it is not rendered until you clone it and insert it. Inside a shadow tree, a <slot> acts as a placeholder where the element's light-DOM children are projected, enabling composition.

const tpl = document.createElement("template");
tpl.innerHTML = `
  <style>
    .panel { border: 1px solid #ccc; padding: 10px; }
    ::slotted(h3) { margin: 0; color: indigo; }
  </style>
  <div class="panel">
    <slot name="title">Default Title</slot>
    <slot>Default body content</slot>
  </div>
`;

class InfoPanel extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    // Clone the inert template content into the shadow root
    shadow.appendChild(tpl.content.cloneNode(true));
  }
}

customElements.define("info-panel", InfoPanel);

// Usage in HTML:
// <info-panel>
//   <h3 slot="title">Welcome</h3>
//   <p>Slotted children appear in the default slot.</p>
// </info-panel>
console.log("InfoPanel registered with named + default slots");

Named slots (slot="title") target a specific <slot name="title">, while unnamed children fall into the default slot. The ::slotted() selector lets the component style projected content.

🔥 Autonomous vs Customized Built-in Elements

There are two kinds of custom elements. Autonomous elements extend HTMLElement and are brand-new tags. Customized built-in elements extend a specific native element (like HTMLButtonElement) and are used with the is attribute, inheriting all the native behavior and accessibility.

// Customized built-in: extends a real <button>
class ConfirmButton extends HTMLButtonElement {
  connectedCallback() {
    this.addEventListener("click", () => {
      console.log("Confirmed! (still a real, accessible button)");
    });
  }
}

// Note the third argument: { extends: "button" }
customElements.define("confirm-button", ConfirmButton, { extends: "button" });

// Usage in HTML:
// <button is="confirm-button">Save</button>
console.log("Customized built-in registered, extends:", "button");

⚠️ Browser support: customized built-ins are the one part of Web Components that is not universal. Chrome, Edge and Firefox support them; Safari has declined to implement them, so customElements.define(..., { extends: "button" }) throws a NotSupportedError there. If you need Safari, either use a polyfill or write an autonomous element that wraps a real <button> inside itself.

Autonomous elements give you a clean slate; customized built-ins let you enhance native elements while keeping their semantics. Choose autonomous for new widgets and customized built-ins when you want to extend existing form controls or links.

🔥 Framework-Agnostic Reuse

Because custom elements are part of the platform, the same component works everywhere. Design systems like those built with Lit or Stencil ship Web Components precisely so a single library can serve React, Angular, Vue, and plain HTML teams at once.

// Define once...
class StarRating extends HTMLElement {
  connectedCallback() {
    const count = Number(this.getAttribute("stars")) || 0;
    this.textContent = "★".repeat(count) + "☆".repeat(5 - count);
  }
}
customElements.define("star-rating", StarRating);

// ...use anywhere:
// Plain HTML:  <star-rating stars="4"></star-rating>
// React JSX:   <star-rating stars="4"></star-rating>
// Vue:         <star-rating :stars="rating"></star-rating>
console.log("Same <star-rating> tag runs in any framework");

This portability is the core promise of Web Components: write a UI element against the browser standard, and it outlives any single framework's lifecycle.

🔥 Common Web Components Mistakes to Avoid

❌ Anti-Patterns

🎯 Mini-Challenge: Build <star-rating>

No blanks this time — the class body is yours. Write a <star-rating label="…" stars="4"> element that redraws whenever stars changes, printing the label, five star characters, and the score.

Careful with one thing: attribute values are always strings. getAttribute('stars') hands you "4", not 4, and "4".repeat is not what you want. Convert it with Number(...) first.

// ---- The stand-in harness (same 30 lines as the worked example) ------
class HTMLElement {
  constructor() { this.attributes = {}; }
  setAttribute(name, value) {
    const old = name in this.attributes ? this.attributes[name] : null;
    this.attributes[name] = value;
    const observed = this.constructor.observedAttributes || [];
    if (observed.includes(name) && this.attributeChangedCallback) {
      this.attributeChangedCallback(name, old, value);
    }
  }
  getAttribute(name) { return name in this.attributes ? this.attributes[name] : null; }
}
const registry = {};
const customElements = {
  define(tag, cls) {
    if (!tag.includes('-')) throw new Error('Custom element name must contain a hyphen: ' + tag);
    registry[tag] = cls;
  },
  get(tag) { return registry[tag]; },
  create(tag) { return new registry[tag](); }
};
const page = {
  children: [],
  append(el) { this.children.push(el); if (el.connectedCallback) el.connectedCallback(); },
  remove(el) { this.children = this.children.filter(c => c !== el); if (el.disconnectedCallback) el.disconnectedCallback(); }
};

// 🎯 MINI-CHALLENGE: write the StarRating component
// 1. Extend HTMLElement.
// 2. static get observedAttributes() should return ['stars'].
// 3. attributeChangedCallback(attr, oldValue, newValue) calls this.render().
// 4. connectedCallback() also calls this.render().
// 5. render() should:
//      - read stars with Number(this.getAttribute('stars') || 0)
//      - read the label with this.getAttribute('label')
//      - console.log:  label + ': ' + '★'.repeat(stars) + '☆'.repeat(5 - stars)
//                      + ' (' + stars + '/5)'
class StarRating extends HTMLElement {
  // your code here
}

customElements.define('star-rating', StarRating);

const review = customElements.create('star-rating');
review.setAttribute('label', 'Sourdough Cafe');   // not observed - no redraw
page.append(review);                              // connectedCallback draws it
review.setAttribute('stars', '4');
review.setAttribute('stars', '2');

// ✅ Expected output when your component is right:
// Sourdough Cafe: ☆☆☆☆☆ (0/5)
// Sourdough Cafe: ★★★★☆ (4/5)
// Sourdough Cafe: ★★☆☆☆ (2/5)
//
// The first line has no stars on purpose: 'label' is not observed, so the
// only thing that draws before the first 'stars' change is connectedCallback,
// and at that point stars is still missing.
// Seeing 'null: ...' instead of the label? label is set before append, so it
// is there — check you are reading 'label' and not 'name'.
// Seeing nothing after the first line? Your observedAttributes is missing.

🎯 Key Takeaways

Practice quiz

Which method registers a new custom element with the browser?

  • customElements.define()
  • document.createCustomElement()
  • HTMLElement.register()
  • window.defineElement()

Answer: customElements.define(). customElements.define('my-tag', MyClass) registers a custom element, mapping a tag name to a class that extends HTMLElement.

What naming rule must a custom element tag follow?

  • It must be a single word
  • It must be all uppercase
  • It must contain a hyphen
  • It must start with x-

Answer: It must contain a hyphen. Custom element names must contain a hyphen (e.g. user-card) to keep them distinct from current and future standard HTML elements.

Which lifecycle callback runs when the element is inserted into the DOM?

  • renderedCallback()
  • connectedCallback()
  • mountedCallback()
  • constructor()

Answer: connectedCallback(). connectedCallback() fires each time the element is added to the document, making it the right place to set up DOM and listeners.

How does a custom element subscribe to attribute changes?

  • By calling watchAttributes()
  • By adding an onattributechange handler
  • Attributes are always observed automatically
  • By defining a static observedAttributes array

Answer: By defining a static observedAttributes array. Only attributes listed in the static observedAttributes getter trigger attributeChangedCallback when they change.

What does element.attachShadow({ mode: 'open' }) create?

  • A shadow root with encapsulated DOM
  • A global style sheet
  • A web worker
  • A new iframe

Answer: A shadow root with encapsulated DOM. attachShadow creates a shadow root: an encapsulated DOM subtree whose markup and styles are scoped to the component.

What is the main benefit of Shadow DOM encapsulation?

  • It disables JavaScript inside the component
  • Its styles and markup are isolated from the rest of the page
  • It forces server-side rendering
  • It makes pages load faster automatically

Answer: Its styles and markup are isolated from the rest of the page. Shadow DOM scopes styles and structure so outside CSS does not leak in and the component's internals do not leak out.

What is the purpose of the <slot> element inside a shadow tree?

  • To load external scripts
  • To create a new shadow root
  • To cache network responses
  • To define a placeholder where light-DOM child content is projected

Answer: To define a placeholder where light-DOM child content is projected. A <slot> is a placeholder in the shadow DOM where the element's light-DOM children are projected, enabling composition.

Why is the <template> element well suited for components?

  • It runs its scripts immediately on parse
  • It is rendered twice for redundancy
  • Its content is parsed but inert until cloned and inserted
  • It automatically connects to a database

Answer: Its content is parsed but inert until cloned and inserted. A <template>'s content is parsed but not rendered or executed until you clone it (e.g. cloneNode(true)) and insert it.

What distinguishes a customized built-in element from an autonomous one?

  • It must be written in TypeScript
  • It extends a native element and is used with the is attribute
  • It requires no class at all
  • It works only inside iframes

Answer: It extends a native element and is used with the is attribute. A customized built-in extends a native element (e.g. class extends HTMLButtonElement) and is used via is='...', inheriting native behavior.

Why are Web Components considered framework-agnostic?

  • They are built on browser standards, so they work in any framework or plain HTML
  • They replace HTML entirely
  • They only run in React
  • They require a build step to function

Answer: They are built on browser standards, so they work in any framework or plain HTML. Web Components are native browser APIs, so a defined custom element can be reused in React, Vue, Angular, or plain HTML.

Continue this course