JavaScript Web Components: Custom Elements, Shadow DOM and Slots
We write our own HTML tag with customElements.define, learn the lifecycle callbacks and observedAttributes, and build a framework-free star rating component with Shadow DOM, template and slot.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
We're defining our own HTML tag. We numbered the three callbacks on purpose, but the numbers don't show the order; they only tell you which callback it is:
class HelloCard extends HTMLElement {
static observedAttributes = ["name"];
constructor() {
super();
console.log("1 constructor");
}
connectedCallback() {
console.log("2 connected");
}
attributeChangedCallback(name, oldValue, newValue) {
console.log("3 attribute", name, oldValue, newValue);
}
}
customElements.define("hello-card", HelloCard);
const card = document.createElement("hello-card");
card.setAttribute("name", "Ada");
document.body.append(card);
What does this code print to the console? Which lines appear in which order, and what is the value of oldValue? Make a guess before you keep reading. We'll give the answer when we talk about the lifecycle.
Why Your Own Tag?
HTML's built-in tags are like a box of Lego: button, input, details all do their job. But if your project uses the same star rating field or the same info card in ten places, you'd rather make your own Lego piece than copy the same HTML, CSS and JavaScript every time. Web Components is the umbrella name for the browser standards that let you do exactly that.
It has three parts. Custom Elements lets you teach the browser a new tag and define its behavior with a class. Shadow DOM isolates the component's internal structure and styles from the rest of the page. <template> and <slot> define the component's skeleton and the places where it takes content from outside. All of them are built into the browser; no extra library, build step or installation needed.
Your First Custom Element
Let's start with a complete file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>First custom element</title>
</head>
<body>
<say-hello name="Ada"></say-hello>
<say-hello></say-hello>
<script>
class SayHello extends HTMLElement {
connectedCallback() {
const name = this.getAttribute("name") ?? "stranger";
this.textContent = `Hello, ${name}!`;
}
}
customElements.define("say-hello", SayHello);
</script>
</body>
</html>
The page shows "Hello, Ada!" and "Hello, stranger!". customElements.define binds the tag name to a class; because the class extends HTMLElement, this is a real DOM element and can use every DOM method we've seen before.
The naming rules are strict: the name must start with a lowercase letter and contain at least one hyphen. The hyphen rule guarantees you'll never clash with any tag added to HTML today or in the future; standard tag names don't contain hyphens. A name can be defined only once per page.
One more detail: the browser sees <say-hello> in the HTML before define is called. Since it doesn't know what it is at that moment, it leaves it as a plain HTMLElement. When define runs, every say-hello on the page is upgraded and gets its real class. You can catch that waiting period with the :defined pseudo-class in CSS, and with the customElements.whenDefined("say-hello") promise in JavaScript.
The Lifecycle
At important moments in a custom element's life, the browser calls specific methods on its class. The constructor runs when the element is created; your first job is to call super(). Here you can set initial values, add event listeners and create a shadow root, but reading or adding attributes and children is off limits; that information may not be ready yet. connectedCallback is called every time the element is added to the page, and disconnectedCallback every time it's removed. attributeChangedCallback(name, oldValue, newValue) runs when an attribute you watch is added, changed or removed. adoptedCallback runs when the element is moved to another document, such as an iframe, and you'll rarely need it. There's also connectedMoveCallback for the newly added Element.moveBefore(), which isn't in every browser yet; if you define it, it's called during the move instead of the disconnect and connect callbacks.
The answer to the opening question is hidden in this order. The output is:
1 constructor
3 attribute name null Ada
2 connected
The constructor runs the moment createElement creates the element. When setAttribute is called, the element isn't on the page yet, but because the attribute is in the watched list, the callback runs right away; since there was no value before, oldValue is null. connectedCallback only arrives once append puts the element on the page. The numbers showed the callback's name, not the order.
disconnectedCallback is very important for cleanup. If an element sets up a timer, adds a listener to document, or starts one of the observers we saw earlier, this is the place to remove them. Try adding and removing the clock below:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Live clock</title>
</head>
<body>
<button id="toggle">Add / remove clock</button>
<live-clock></live-clock>
<script>
class LiveClock extends HTMLElement {
#timer = null;
connectedCallback() {
this.#update();
this.#timer = setInterval(() => this.#update(), 1000);
console.log("Clock started");
}
disconnectedCallback() {
clearInterval(this.#timer);
console.log("Clock stopped");
}
#update() {
this.textContent = new Date().toLocaleTimeString();
}
}
customElements.define("live-clock", LiveClock);
const clock = document.querySelector("live-clock");
document.getElementById("toggle").addEventListener("click", () => {
if (clock.isConnected) clock.remove();
else document.body.append(clock);
});
</script>
</body>
</html>
Without disconnectedCallback, the clock you removed would keep running in the background every second. Also notice that connectedCallback runs again when you add the element back; this callback doesn't arrive once, it arrives on every connection.
Watching Attributes
attributeChangedCallback is only called for attributes named in the static observedAttributes array. It's a performance choice: the browser doesn't bother you on every attribute change. If you change an attribute that isn't on the list, the callback never runs.
People using your component usually expect two ways in: writing value="3" in HTML and saying el.value = 3 in JavaScript. The cleanest way to keep the two in sync is to treat the attribute as the single source of truth and tie the property to it:
get value() {
return Number(this.getAttribute("value") ?? 0);
}
set value(newValue) {
this.setAttribute("value", String(newValue));
}
A value written to the property goes to the attribute, the attribute change triggers attributeChangedCallback, and you redraw the screen. Whichever way it comes in, the change goes through the same door. The getters and setters we saw in the Intermediate series fit perfectly here.
Shadow DOM and Style Isolation
If you write the component's inner HTML directly inside the element, the page's CSS applies to it too, and the component's CSS leaks out into the page. Shadow DOM solves that mess. Think of it as an apartment: what color you paint the walls inside doesn't concern the outside, and the paint in the hallway doesn't get in. But air passes under the door; inherited properties like color and font seep inside.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Shadow DOM</title>
<style>
p { color: red; border: 2px solid red; }
</style>
</head>
<body>
<p>Paragraph on the page</p>
<shadow-box></shadow-box>
<script>
class ShadowBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host { display: block; padding: 12px; background: #eef; }
p { color: navy; }
</style>
<p>Paragraph inside the Shadow DOM</p>
`;
}
}
customElements.define("shadow-box", ShadowBox);
console.log(document.querySelectorAll("p").length); // 1
</script>
</body>
</html>
The red-bordered style on the page doesn't reach the paragraph inside the shadow, and the navy style inside doesn't affect the paragraph outside. The :host selector targets the component itself, that is, <shadow-box>. querySelectorAll("p") finds only one paragraph, because the inside of the shadow is also hidden from the page's normal queries; to reach in, you use element.shadowRoot.querySelector().
With mode: "open", shadowRoot is accessible from outside. If you say "closed", element.shadowRoot returns null. It isn't a security measure, just a "don't touch" sign; in practice most components use open. When you want to theme a component from outside, use CSS custom properties: variables like --main-color cross the shadow boundary, and the component reads them as var(--main-color, blue).
template and slot: Taking Content from Outside
The <template> tag we saw earlier is an HTML mold that isn't displayed on the page but is ready to be cloned. Combined with Shadow DOM, it becomes the component's skeleton. <slot> elements are the holes in that skeleton where you place the user's content:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Slot</title>
</head>
<body>
<template id="card-template">
<style>
.card { border: 1px solid #ccc; border-radius: 8px; padding: 12px; margin: 8px; max-width: 280px; }
::slotted(h3) { margin: 0 0 8px; color: #2563eb; }
</style>
<div class="card">
<slot name="title"><h3>Untitled card</h3></slot>
<slot></slot>
</div>
</template>
<info-card>
<h3 slot="title">Coffee</h3>
<p>Keep it under two cups a day.</p>
</info-card>
<info-card>
<p>This card has no title.</p>
</info-card>
<script>
class InfoCard extends HTMLElement {
constructor() {
super();
const template = document.getElementById("card-template");
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
}
}
customElements.define("info-card", InfoCard);
</script>
</body>
</html>
An element with the slot="title" attribute goes into the slot with name="title". The unnamed slot takes everything else. Since the second card has no heading, the fallback content inside the slot, "Untitled card", shows up. ::slotted(h3) styles the h3 elements that come from outside and land directly in a slot.
There's a subtle point here: content that goes into a slot isn't moved into the shadow DOM; it stays on the page and is only displayed there. That's why the page's CSS can still style it and document.querySelector can still find it. What Shadow DOM isolates is the component's own internal structure, not the content the user provides.
A Framework-Free Component: Star Rating
Let's put everything together and write a truly reusable component. <star-rating> takes its value from an attribute, changes on click, fires an event to the outside when it changes, and can be sized with a CSS variable:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Star rating</title>
<style>
star-rating:not(:defined) { visibility: hidden; }
</style>
</head>
<body>
<star-rating value="3">Coffee</star-rating>
<star-rating value="7" max="10" style="--star-size: 18px">Movie</star-rating>
<p id="last"></p>
<script>
const template = document.createElement("template");
template.innerHTML = `
<style>
:host { display: flex; align-items: center; gap: 8px; font-size: var(--star-size, 28px); }
button { all: unset; cursor: pointer; color: #d1d5db; }
button.filled { color: #f59e0b; }
button:focus-visible { outline: 2px solid #2563eb; border-radius: 4px; }
</style>
<slot></slot>
<div id="stars"></div>
`;
class StarRating extends HTMLElement {
static observedAttributes = ["value", "max"];
constructor() {
super();
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
this.shadowRoot.getElementById("stars").addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
this.value = Number(button.dataset.rating);
this.dispatchEvent(new CustomEvent("rating-change", {
detail: { value: this.value },
bubbles: true,
}));
});
}
get value() { return Number(this.getAttribute("value") ?? 0); }
set value(newValue) { this.setAttribute("value", String(newValue)); }
get max() { return Number(this.getAttribute("max") ?? 5); }
set max(newValue) { this.setAttribute("max", String(newValue)); }
connectedCallback() { this.#render(); }
attributeChangedCallback() { this.#render(); }
#render() {
const container = this.shadowRoot.getElementById("stars");
container.replaceChildren();
for (let i = 1; i <= this.max; i++) {
const button = document.createElement("button");
button.textContent = "★";
button.dataset.rating = i;
button.className = i <= this.value ? "filled" : "";
button.setAttribute("aria-label", `Rate ${i}`);
container.append(button);
}
}
}
customElements.define("star-rating", StarRating);
document.addEventListener("rating-change", (event) => {
document.getElementById("last").textContent =
`New rating for ${event.target.textContent}: ${event.detail.value}`;
});
</script>
</body>
</html>
We attached the click to the container rather than to each button; that's the event delegation we saw earlier. The clicked star updates the value property, which changes the attribute, and the attribute redraws the screen through attributeChangedCallback. To notify the outside we use the CustomEvent we also saw earlier; thanks to bubbles: true, the event travels all the way up to document. The :not(:defined) rule at the top of the page keeps the raw tags from flashing on screen until the script runs. Type document.querySelector("star-rating").max = 8 in the console and see the result.
Common Pitfalls
Naming errors are the first hurdle. Call define with a name that has no hyphen and you get a SyntaxError; define the same name a second time and you get a NotSupportedError. The second one is common on pages that load the same script twice; you can check first with customElements.get("star-rating").
Custom elements don't self-close. If you write <star-rating />, the HTML parser ignores the trailing slash and puts everything that follows inside this element. Always write <star-rating></star-rating>.
Reading attributes in the constructor, or adding children and attributes to the element there, is another classic mistake. For an element created with createElement, the attributes don't exist yet during the constructor, and if you try to add an attribute in the constructor the browser reports an error. Move that work to connectedCallback. Don't forget that this callback can run more than once either; if some setup must happen only once, guard it with a flag.
An event you fire from an element inside the Shadow DOM stops at the shadow boundary by default. In our example there was no problem, because we fired the event from the host itself, that is, from this. If you fired it from a button inside, you would need composed: true along with bubbles: true for it to be heard outside.
Finally, customized built-in elements, written as <button is="fancy-button"> and extending an existing tag, aren't supported by Safari, and Safari doesn't plan to support them. Autonomous elements that extend HTMLElement, like the ones we built in this post, work in every browser, so stick with those.
A Small Experiment
★☆☆ Add a watched color attribute to the say-hello component. When you type document.querySelector("say-hello").setAttribute("color", "tomato") in the console, the text color should change.
★★☆ Predict the output before running it, then try it in the console:
class CounterBox extends HTMLElement {
connectedCallback() { console.log("connected"); }
disconnectedCallback() { console.log("disconnected"); }
}
customElements.define("counter-box", CounterBox);
const a = document.createElement("counter-box");
const b = document.createElement("div");
document.body.append(a);
b.append(a);
document.body.append(b);
★★★ Add keyboard support to star-rating: while the component has focus, the left and right arrow keys should decrease and increase the rating by one. Also add a readonly attribute; while it's present, clicks and keys must not change the rating.
Web Components are a nice example of how browser standards mature over the years. In the next post, which is also the last post of this series, we'll follow the same kind of maturing in the language itself: TC39's stages, Temporal, which is coming to replace Date, and resource management with using.