Web Components
Build reusable custom elements with lifecycle callbacks, Shadow DOM, templates, slots, styling hooks, and events that cross the shadow boundary correctly.
- 01Register custom elements safelyPick a valid tag name, register it only once, and know when each lifecycle callback runs.
- 02Build with Shadow DOMChoose open or closed mode, and style the inside with
:host, slots, parts, and custom properties. - 03Predict what crosses the boundaryTell when slot fallback text shows, which target listeners outside see, and when a custom event gets out.
Build a new kind of element
HTML gives you ready-made elements, such as <button> and <input>. Web Components let you make your own. They are features built into the browser, so you need no framework and no build step.
With Web Components you can:
- Teach the browser a new tag, such as
<cf-wc-counter>, and give it a class that says how it behaves. - Run your own code when the element is added to the page or taken off it.
- Hide the element's inside parts in a shadow root. That is a private area that page styles can't reach.
- Leave openings, called slots, where content from the page shows through.
This lesson uses ideas from Class basics, Creating & changing elements, Bubbling & capturing, and Custom events. The new idea here is the boundary: the line between the element's private inside and the rest of the page. You will learn what stays inside, what the page controls, and what is allowed to cross.
A LEGO box comes with standard pieces. Now imagine you design a new piece and send its instruction sheet to the factory. The factory learns it once. From then on, every piece of that shape is made the way your sheet says.
- In real life: A new LEGO piece shape
- In JavaScript: A new tag name, such as
cf-wc-counter - In real life: The instruction sheet for the piece
- In JavaScript: The JavaScript class that says how the element behaves
- In real life: The factory learns the piece once
- In JavaScript:
customElements.defineregisters the name once - In real life: Every piece of that shape follows the sheet
- In JavaScript: Every matching tag on the page becomes your element
A Web Component is a reusable element you make yourself. It is built from a custom element: a new tag name joined to a class. It can also use a shadow root, templates, and slots.
Custom elements and their lifecycle
INTERACTIVEA custom element is a new HTML tag that you define. You make one in two steps:
- Write a class that
extends HTMLElement.HTMLElementis the class behind every ordinary HTML element, so your class gets everything they can do. - Register a tag name for it with
customElements.define(name, Class).customElementsis the browser's registry: its list of known custom tag names.
The tag name has three rules:
- It starts with a lowercase letter.
- It has no capital letters.
- It contains a hyphen (
-), such asprofile-card.
The hyphen keeps your names apart from built-in tags. No new built-in HTML tag will ever have a hyphen, so a future tag can never take your name.
Each name can be registered only once per page. If you call customElements.define("cf-wc-counter", Counter) a second time, it throws a NotSupportedError. On this site, a lesson's code can run more than once. That happens when you leave a lesson and come back without a full reload. It also happens in React's Strict Mode, which runs setup code twice while the site is being built. So every demo here does two things:
- It registers elements only in the browser, never on the server.
- It first calls
customElements.get(name). That gives back the class if the name is already taken, orundefinedif the name is free.
Give each lesson's tags a name start, like cf-wc-. Check the name with customElements.get before every define. Then make the element inside an empty container that React leaves alone. (React reaches that container through a ref, its way to hold a real page element.) React draws the container. Your custom element controls everything inside it.
class ProfileCard extends HTMLElement {}
customElements.define("cf-wc-profile-card", ProfileCard);
customElements.define("cf-wc-profile-card", ProfileCard);Line 2 registers the name. Line 3 tries to register it again. The registry does not swap in the new class. It throws NotSupportedError instead. That is why code that can run twice needs a check first.
const card = document.createElement("cf-wc-upgrade-demo");
console.log(card instanceof HTMLElement);
document.body.append(card);
class UserCard extends HTMLElement {}
customElements.define("cf-wc-upgrade-demo", UserCard);
console.log(card instanceof UserCard);Line 1 makes an element called cf-wc-upgrade-demo. No class is registered for that name yet, so it is a plain element. Line 2 prints true: it is an HTMLElement. Line 3 puts it on the page. Line 4 writes the class, and line 5 registers it. The browser now finds the tag already on the page and turns it into a UserCard. This is called an upgrade. Line 6 prints true.
define() runs, the browser upgrades that same element into a UserCard.An element that is not on the page is not upgraded by itself. If you need it upgraded right away, call customElements.upgrade(element).
Sometimes your code must wait until a tag's class is registered. For example, it may want to call a method that only the class has. customElements.whenDefined("profile-card") gives you a promise: an object that settles later, here when the name is registered. It only waits. It never registers the class for you.
const name = "cf-wc-ready-badge";
const ready = customElements.whenDefined(name);
console.log(ready instanceof Promise);
class ReadyBadge extends HTMLElement {}
customElements.define(name, ReadyBadge);
console.log(customElements.get(name) === ReadyBadge);Line 2 starts waiting before the class even exists. Line 3 prints true, because ready is a promise. Line 5 registers the class, and that settles the promise. Line 6 prints true: the registry now gives back ReadyBadge for the name. Waiting doesn't make a second define safe, so you still need the check from before.
The browser also calls special methods on your element at key moments in its life. They are called lifecycle callbacks:
- The
constructorruns when the element is created. connectedCallbackruns each time the element is added to the page.disconnectedCallbackruns each time the element is taken off the page.attributeChangedCallbackruns when a watched attribute changes. You list the attributes to watch instatic observedAttributes. Astaticfield belongs to the class itself, not to each element.
A building manager sends a tenant notices: "You have moved in." "You have moved out." The tenant also hears the doorbells they installed, and only those. The tenant decides what to do about each notice.
- In real life: A notice: you have moved in
- In JavaScript:
connectedCallbackruns: the element was added to the page - In real life: A notice: you have moved out
- In JavaScript:
disconnectedCallbackruns: the element was taken off the page - In real life: A doorbell the tenant chose to install
- In JavaScript:
attributeChangedCallbackruns, but only for names inobservedAttributes
Elements have attributes and properties. An attribute is written in HTML, like count="2", and it always holds text. A property is a value on the JavaScript object, like element.count, and it can be any type. The counter below reflects its property: setting element.count also writes the count attribute.
Try the counter. It is a real custom element that watches count and step. It starts with count="2" and step="3". Use the buttons to:
- set the
countattribute to10; - set the
countproperty to21; - read both back.
You can also click the counter's own button. It adds the step, 3, to the count. The log lists each callback that ran.
class CounterBadge extends HTMLElement { static observedAttributes = ["count", "step"]; set count(value) { this.setAttribute("count", String(value)); } connectedCallback() { this.render(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) this.render(); }} customElements.define("cf-wc-counter", CounterBadge);Create the element to read attributes and properties.
class ClickCounter extends HTMLElement {
count = 0;
connectedCallback() {
this.innerHTML = "<button>0</button>";
this.querySelector("button").addEventListener("click", () => {
this.querySelector("button").textContent = String(++this.count);
});
}
}
customElements.define("cf-wc-click-counter", ClickCounter);
const counter = document.createElement("cf-wc-click-counter");
document.body.append(counter);
counter.querySelector("button").click();
console.log(counter.querySelector("button").textContent);Line 2 starts the count at 0. When the element is added to the page, connectedCallback (line 3) puts a button inside it (line 4). Line 5 listens for clicks. Each click adds 1 to the count and shows the new number (line 6). Line 14 clicks the button once, so line 15 prints 1.
const countAttribute = "2";
const countProperty = Number(countAttribute);
console.log(typeof countAttribute, countAttribute);
console.log(typeof countProperty, countProperty);Line 1 holds the count as text, the way an attribute stores it. Line 2 turns that text into a number with Number(). Lines 3 and 4 print the type and the value of each: string 2, then number 2. If a property and an attribute describe the same thing, keep them in step. Turn the text into a number where your component reads it, and check that the result really is a number.
const button = document.createElement("button");
button.setAttribute("disabled", "false");
console.log(button.disabled);
button.removeAttribute("disabled");
console.log(button.disabled);Some attributes, like disabled, are on/off switches. For these, only one thing matters: is the attribute there or not? Line 2 sets disabled to the text "false". The attribute is still there, so line 3 prints true. Line 4 removes it, so line 5 prints false.
The order of lifecycle callbacks
STEP THROUGHThe order matters. Many components start work when they are added to the page: they load data, add listeners, or measure their size. They clean that work up when they are taken off. So:
- The
constructorshould only set up the element's own starting values. The element is not on the page yet. connectedCallbackcan run more than once for the same element. It runs again each time the element is moved, or taken off and added back.
The step-through below copies the order a real browser uses. It is a model written in plain JavaScript, so the site's tests can check it. The same order was also checked in Chrome. Pick a setting, then guess: does the attribute callback come before or after the first connectedCallback?
Choose when the attribute is set, then step through. This model follows the order a real browser uses, and the same order was checked in Chrome.
script
el.setAttribute("count", "2");left.append(el);right.append(el);el.remove();left.append(el);Line 4 moves the element from left to right. A move takes the element off the page and then adds it again. So you see disconnectedCallback first, then connectedCallback.
Shadow DOM: a private inside for your element
INTERACTIVEThe DOM is the tree of elements that makes up a page. Shadow DOM gives one element its own small, separate tree. That tree starts at a shadow root. The element that holds the shadow root is called the host. You make one with this.attachShadow({ mode: "open" }).
The boundary works both ways:
- CSS rules on the page don't reach the elements inside the shadow root.
- CSS rules inside the shadow root don't leak out to the page.
The host itself is still a normal element on the page. You can place it, set its attributes, and listen for its events.
p rule colors the page's paragraph. The paragraph inside the shadow root is out of its reach.A snow globe has a little scene inside. Dust on your desk can't get onto the tiny trees. The tiny snow can't spill onto your desk. Still, the globe sits on your desk like any other object. Its maker can also add knobs on the outside for you to turn.
- In real life: The little scene inside the globe
- In JavaScript: The elements inside the shadow root
- In real life: The glass
- In JavaScript: The boundary that keeps styles in and out
- In real life: Knobs the maker adds on the outside
- In JavaScript: The styling hooks the component offers, like
::part()and CSS custom properties
Try the lab below. It shows a page paragraph and a component with its own shadow paragraph. The page has a CSS rule that colors its paragraphs red.
- The rule colors the page paragraph, but not the shadow paragraph.
- Switch between open and closed mode. Watch what
element.shadowRootgives back. - Switch the accent color. It is a CSS custom property, a variable in CSS written like
--wc-accent. Custom properties pass through the boundary, so the page can change the shadow text color on purpose.
The lab's code also uses :host, ::slotted(), and part. You will meet each one below.
class NoticeCard extends HTMLElement { constructor() { super(); const root = this.attachShadow({ mode: "open" }); root.innerHTML = `<style> :host { --wc-accent: royalblue; display: block; } p { color: var(--wc-accent); } ::slotted(strong) { text-decoration: underline; } [part="label"] { font-weight: bold; } </style> <p part="label">Shadow paragraph</p> <slot name="title">Fallback title</slot> <slot>Fallback body</slot>`; }}Open mode: element.shadowRoot gives the shadow root. The page's paragraph rule stays outside.
::slotted(strong), and part="label". Closed mode hides shadowRoot, but it is not a security feature.:host {
display: block;
--wc-accent: royalblue;
}
cf-wc-counter::part(button) {
border-color: var(--wc-accent);
}This CSS shows two tools:
:host(lines 1 to 4) is written inside the shadow root. It styles the host element itself.::part(button)(lines 6 to 8) is written on the page. It styles an inside element that the component marked withpart="button".
Page CSS can't reach that button any other way. Only add a part name if you are happy to keep it working for everyone who uses your component.
mode: "open" lets any script read element.shadowRoot. mode: "closed" makes that property give null. Closed mode can stop other code from using your inside parts by accident. It does not keep anything secret.
Templates and slots
INTERACTIVEA <template> element holds HTML that the browser reads but does not show. Nothing inside it appears or runs. To use it, you copy its contents with template.content.cloneNode(true). Components copy one template into each new element, so every element of that tag gets the same inside parts.
A slot is an opening in the shadow tree. The host's own children, the ones the page wrote between the host's tags, show up through it. Those children are called the light DOM: they belong to the page, not to the shadow root.
- A named slot, like
<slot name="title">, shows the children that have a matchingslot="title"attribute. - The default slot, a
<slot>with no name, shows all the other children. - Text written inside a slot is fallback text. It shows only when no child goes to that slot.
<cf-wc-user-card>
<strong slot="name">Asha</strong>
<span>Writes tools</span>
</cf-wc-user-card>The strong element has slot="name", so it shows in the slot named name. The span has no slot name, so it shows in the default slot. This is only the page's side of the HTML, not a whole component, so there is nothing to run.
Asha asks for the name slot. Writes tools has no slot name, so it goes to the default slot.Slotted children are not moved into the shadow root. They stay children of the host. The slot only decides where they appear. To style them from inside the component, use ::slotted(). It only matches the slotted children themselves, not elements nested inside them.
In the lab, switch the title child and the body child on and off. Watch the fallback text appear when a slot is empty. The slotchange event fires on a slot each time the children shown in it change.
const template = document.querySelector("#profile-template");const root = host.attachShadow({ mode: "open" });root.append(template.content.cloneNode(true)); root.querySelector("slot[name='title']") .addEventListener("slotchange", logAssignedNodes); host.innerHTML = `<strong slot="title">Ada</strong><span>Writes tools</span>`;Expected: title: Ada | default: Writes tools.
Styling and events across the boundary
INTERACTIVEEvery crossing of the boundary should be on purpose. For styling, a component has three tools:
- CSS custom properties, for theme values like a brand color;
::part(), for named inside elements;- slots, for content the page owns.
For events, two words matter:
- Retargeted: when an event leaves a shadow root, listeners outside see the host as
event.target, not the inside element. (event.targetis the element where the event started.) - Composed: an event with
composed: trueis allowed to cross the boundary. Without it, the event stays inside the shadow root.
Inside a shop, a question starts with one worker. People outside never see that worker. To them, the answer comes from the shop's front counter. Some questions the shop keeps inside, and they never reach the street at all.
- In real life: A question starts with one worker inside the shop
- In JavaScript: An event starts on an element inside a shadow root
- In real life: People outside only see the front counter answer
- In JavaScript: Listeners outside see the host as
event.target - In real life: The shop decides to pass the question outside
- In JavaScript:
composed: truelets a custom event cross
Try the lab. Click the button inside the shadow root, and compare the target seen inside with the target seen outside. Then send a custom event with composed set to false, then to true, and see whether the page hears it.
shadowButton.addEventListener("secret", (event) => { console.log("inside target:", event.target.localName);}); document.addEventListener("secret", (event) => { console.log("outside target:", event.target.localName);}); shadowButton.dispatchEvent(new CustomEvent("secret", { bubbles: true, composed: true,}));composed is false, so the custom event moves up inside the shadow root and stops at the boundary.
shadowButton.addEventListener("secret", (event) => {
console.log("inside target:", event.target.localName);
});
document.addEventListener("secret", (event) => {
console.log("outside target:", event.target.localName);
});
shadowButton.dispatchEvent(new CustomEvent("secret", {
bubbles: true,
composed: true,
}));Here the button lives in the shadow root of a div host. Line 9 sends a custom event called secret from the button. Line 10 lets it bubble, which means move up from the button to its parents. Line 11 lets it cross the boundary. Both listeners run:
- The inside listener (line 1) sees the real button, so it prints
inside target: button. - The outside listener (line 5) sees the host, so it prints
outside target: div.
button. The listener on document only sees the host, a div.Leave out composed, or set it to false, when the event should stay private.
Now sort some common needs by where they belong. Ask the same question each time: should the page own this, should the component own it, or should it be a planned bridge between them?
- The card's title text, written by the page
- A private +1 button that the component owns
- A spot that shows whatever paragraph the page puts inside the component
- One label that page CSS may style, and nothing else
- The input whose value a form sends
- An inside wrapper copied into each new card
- Default text shown until the page gives a child
- One border element the page may color
Sort each need into light DOM, Shadow DOM, slot, or part. Each explanation says who owns it.
Where you will use this
Web Components work best when one piece of a page must work in many places, whatever framework each page uses. For example:
- buttons from a company's design system (its shared set of styled parts), used by several teams;
- help widgets added to plain HTML pages;
- a map picker that plain HTML pages can use;
- a small dashboard card shared by a React page and a page built on the server.
They are not always the best choice:
- If a widget is only used inside one React app, a React component is often simpler.
- If a form must send its own input fields directly, keeping them in the light DOM may be easier than Shadow DOM.
- If page CSS needs to style many inside parts, offer a small set of parts. Don't let users depend on your inside class names.
The table below puts the five pieces side by side. Read each row as: this piece is in charge of this, it does this job, and here is a common mistake.
| Piece | What it is in charge of | What it does | A common mistake |
|---|---|---|---|
| Custom element | The new tag and its class. | It adds a new HTML element with lifecycle callbacks. | Registering the same name twice. |
| Shadow DOM | The element's inside parts and their styles. | It keeps page CSS out and inside CSS in. | Thinking closed mode keeps secrets. |
| Template | HTML that is ready to copy. | It gives each element a copy of the same inside parts. | Expecting it to show up by itself. |
| Slot | Where the host's children appear. | It shows the page's content inside the component. | Expecting ::slotted() to style elements nested inside the slotted ones. |
| Part | One named inside element. | It lets page CSS style that element. | Marking too many inside elements as parts. |
Modern browsers can also make a shadow root straight from HTML, with no JavaScript. You write <template shadowrootmode="open"> inside the host. This is called declarative Shadow DOM. Check that the browser supports it before you rely on it. One way is to test whether "shadowRootMode" in HTMLTemplateElement.prototype is true.
A custom element does not join a form by itself, even with Shadow DOM. To let it send a value with a form:
- Set
static formAssociated = trueon the class. - Check that
attachInternals()exists. It gives you anElementInternalsobject, which lets the element set its form value. - If it doesn't exist, keep a normal
<input>or another fallback.
class CheckoutField extends HTMLElement {
static formAssociated = true;
}
console.log(CheckoutField.formAssociated);
console.log(typeof HTMLElement.prototype.attachInternals === "function");Line 2 turns form support on for the class. Line 5 prints true. Line 6 prints true when the browser has attachInternals().
Common misconceptions
"A custom element must use Shadow DOM."
No. A custom element can keep its parts in the light DOM, use Shadow DOM, or show nothing at all. These features work well together, but each one also works alone.
"Closed shadow roots are secure."
Closed mode only makes element.shadowRoot give null to normal code. It does not protect secrets, permissions, or business rules.
"Any attribute change runs attributeChangedCallback."
It only runs for names listed in observedAttributes. If a name is missing from that list, nothing runs.
"Slots move children into the shadow root."
Slotted children stay children of the host, in the light DOM. The slot only decides where they show up.
"All events leave a shadow root."
Most user events, like clicks, are composed, so they cross. Many other events are not. A custom event needs composed: true if listeners outside should hear it.
"Page CSS can style anything with a clever selector."
Shadow DOM blocks normal page selectors. The component chooses what the page may style: custom properties, slots, and parts.
Practice: where the boundary goes
5 EXERCISESIs profile-card a valid custom element name? Answer yes or no.
Yes. profile-card starts with a lowercase letter and has a hyphen. profilecard would not work, because it has no hyphen.
This short program prints callback names in the order a real element runs them when you set a watched attribute and then add the element to the page. Which name does it print first?
console.log("constructor");
console.log("attributeChangedCallback");
console.log("connectedCallback");It prints constructor first. A real custom element is built before any attribute callback or connectedCallback can run.
A count property setter runs setAttribute("count", String(value)). Which text does it write when the value is 5?
const oldAttribute = "2";
const next = 5;
console.log(String(next));const oldAttribute = "2";
const next = 5;
console.log(String(next));It writes the text 5. The component can turn it back into a number when it reads the attribute.
You leave the lesson, come back, and see NotSupportedError. Write the check you would add around customElements.define.
const name = "cf-wc-profile";
if (!customElements.get(name)) {
customElements.define(name, ProfileCard);
}Check the registry before you register. On a second run, customElements.get(name) gives back the class, so define is skipped and NotSupportedError never happens.
Plan a reusable product card. Decide where each of these belongs: the product title, the layout of its inside buttons, the theme color, and a save event.
One good plan: the page writes the name and price as children of the card, and they show in named slots. The layout and a save button live in the shadow root. The theme color comes in through a CSS custom property. Add part="button" only to parts you promise to keep. Send a custom event called product-save with composed: true, so the page can hear it.
Quiz: check your understanding
8 QUESTIONSAnswer once, then read every explanation. The wrong answers show mistakes that even experienced developers make.
Question 1 of 8Which custom element name is valid?
Choose an answer to see the explanation.
Question 2 of 8What happens if the same name is registered twice on one page?
Choose an answer to see the explanation.
Question 3 of 8Which callback runs when a watched attribute changes?
Choose an answer to see the explanation.
Question 4 of 8A
countsetter writes the number 5 withsetAttribute. What does this print?Read the code, then predictconst oldAttribute = "2"; const next = 5; console.log(String(next));Choose an answer to see the explanation.
Question 5 of 8In closed mode, what does
element.shadowRootgive back?Read the code, then predictconsole.log(null);Choose an answer to see the explanation.
Question 6 of 8What can
::slotted(p)style?Choose an answer to see the explanation.
Question 7 of 8In the event lab, what target does a listener on
documentsee for a click inside the shadow root?Read the code, then predictconsole.log("cf-wc-event-card");Choose an answer to see the explanation.
Question 8 of 8Which custom event option lets it leave a shadow root?
Choose an answer to see the explanation.
Key takeaways
- A custom element name starts with a lowercase letter and has a hyphen. You can register each name only once per page.
connectedCallbackanddisconnectedCallbackrun when the element is added to the page and taken off.attributeChangedCallbackruns only for names inobservedAttributes.- Shadow DOM keeps an element's inside parts and styles private. Open or closed mode changes what scripts can read. It is not security.
- A template holds HTML to copy. Slots show the page's children inside the component.
::part()and custom properties let the page style chosen parts. - Events that leave a shadow root are retargeted to the host. A custom event needs
composed: trueto leave at all.
Remember the one-liner.
A Web Component teaches the browser a new tag. It owns its inside parts and chooses which content, styles, and events can cross its boundary.
Up next: The iteration protocols.