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.
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);<!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>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.
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);<!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>