AdvancedJavaScript · Lesson 7 of 9

Web Components

Create your own reusable HTML elements with Custom Elements, Shadow DOM and slots.

Web Components are the browser's built-in component system — no framework needed. A Custom Element is a class extending HTMLElement, registered with a tag name containing a hyphen, such as <grade-badge>. It works in plain HTML and in any framework.

Shadow DOM gives the component private markup and styles that don't leak out and aren't affected by the page's CSS. <slot> lets users of your component pass in their own content.

Lifecycle callbacks: connectedCallback runs when the element is added to the page, and attributeChangedCallback runs when one of its observedAttributes changes. Communicate outwards by dispatching custom events.

grade-badge.jsJavaScript
const GRADES = [
  [75, "A", "#2e8b2a"], [65, "B", "#2f7fc4"], [45, "C", "#6b3fa0"], [30, "D", "#b5541e"], [0, "F", "#b00020"],
];

class GradeBadge extends HTMLElement {
  static observedAttributes = ["score"];

  constructor() {
    super();
    this.attachShadow({ mode: "open" }).innerHTML = `
      <style>
        :host { display: inline-flex; align-items: center; gap: 8px; font-family: system-ui; }
        .badge { color: white; border-radius: 6px; padding: 2px 8px; font-weight: 700; cursor: pointer; }
      </style>
      <span class="badge" part="badge"></span>
      <slot>Unnamed student</slot>
    `;
    this.shadowRoot.querySelector(".badge").addEventListener("click", () => {
      this.dispatchEvent(new CustomEvent("grade-click", {
        detail: { score: this.score, grade: this.grade },
        bubbles: true,
        composed: true,                  // crosses the shadow boundary
      }));
    });
  }

  get score() {
    return Number(this.getAttribute("score") ?? 0);
  }

  get grade() {
    return GRADES.find(([min]) => this.score >= min)[1];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    const [, grade, color] = GRADES.find(([min]) => this.score >= min);
    const badge = this.shadowRoot.querySelector(".badge");
    badge.textContent = `${grade} (${this.score})`;
    badge.style.background = color;
  }
}

customElements.define("grade-badge", GradeBadge);
index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Web Components</title>
    <script type="module" src="grade-badge.js"></script>
    <script type="module" src="main.js"></script>
  </head>
  <body>
    <p><grade-badge score="88">Amina Hassan</grade-badge></p>
    <p><grade-badge score="54">Baraka Mushi</grade-badge></p>
    <p><grade-badge score="12"></grade-badge></p>
    <button id="bump">Give Baraka +15</button>
    <p id="clicked"></p>
  </body>
</html>
main.jsJavaScript
document.addEventListener("grade-click", (event) => {
  const { score, grade } = event.detail;
  document.querySelector("#clicked").textContent = `Clicked a ${grade} (${score})`;
});

document.querySelector("#bump").addEventListener("click", () => {
  const baraka = document.querySelectorAll("grade-badge")[1];
  baraka.setAttribute("score", Math.min(100, baraka.score + 15));   // re-renders automatically
});

Key points

  • Custom Elements are classes extending HTMLElement, registered with a hyphenated tag.
  • Shadow DOM encapsulates markup and styles; <slot> accepts user content.
  • React to attributes with observedAttributes; talk to the page with custom events.

Exercise

Build a <student-card> component with name, form and photo-URL attributes, a slot for extra notes, and a "Show results" button that fires a show-results event with the student's name. Use it three times on a page.

Show solution

Try the exercise yourself first — then compare your approach with this one.

<student-card> reads its attributes on every render and re-renders when one changes. User-provided notes go into the <slot>, so they keep the page's own markup. The button dispatches a show-results event with composed: true so it crosses the shadow boundary and the page can listen for it.

student-card.jsJavaScript
class StudentCard extends HTMLElement {
  static observedAttributes = ["name", "form", "photo"];

  constructor() {
    super();
    this.attachShadow({ mode: "open" }).innerHTML = `
      <style>
        :host { display: flex; gap: 12px; align-items: center; padding: 12px; border: 1px solid #dde2e8; border-radius: 12px; font-family: system-ui; margin: 8px 0; }
        img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; background: #e9edf2; }
        h3 { margin: 0; font-size: 1rem; }
        p { margin: 2px 0; color: #4a5568; }
      </style>
      <img alt="" />
      <div>
        <h3></h3>
        <p class="form"></p>
        <slot name="notes"><p>No notes yet.</p></slot>
        <button type="button">Show results</button>
      </div>
    `;
    this.shadowRoot.querySelector("button").addEventListener("click", () => {
      this.dispatchEvent(new CustomEvent("show-results", {
        detail: { name: this.getAttribute("name") },
        bubbles: true,
        composed: true,
      }));
    });
  }

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

  render() {
    const root = this.shadowRoot;
    root.querySelector("h3").textContent = this.getAttribute("name") ?? "Unnamed student";
    root.querySelector(".form").textContent = `Form ${this.getAttribute("form") ?? "?"}`;
    const photo = this.getAttribute("photo");
    const img = root.querySelector("img");
    if (photo) img.src = photo;
    else img.removeAttribute("src");
  }
}

customElements.define("student-card", StudentCard);
index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Student cards</title>
    <script type="module" src="student-card.js"></script>
    <script type="module">
      document.addEventListener("show-results", (e) => {
        document.querySelector("#selected").textContent = `Showing results for ${e.detail.name}`;
      });
    </script>
  </head>
  <body>
    <student-card name="Amina Hassan" form="4">
      <p slot="notes">Head of the debate club.</p>
    </student-card>
    <student-card name="Juma Said" form="3"></student-card>
    <student-card name="Neema Kimaro" form="4">
      <p slot="notes">Best Biology project 2026.</p>
    </student-card>
    <p id="selected"></p>
  </body>
</html>

Check your understanding

  1. Which tag name is valid for a custom element?

  2. What does Shadow DOM give a component?

  3. What is a <slot> for?

  4. Why set composed: true on a component's CustomEvent?

Ask AI