Web Components
Build reusable custom elements with lifecycle callbacks, Shadow DOM, templates, slots, styling hooks, and events that cross the shadow boundary correctly.
- 01Define safe custom elementsUse hyphenated names, guard duplicate definitions, and understand lifecycle callbacks.
- 02Build with Shadow DOMChoose open or closed roots, style with host rules, slots, parts, and custom properties.
- 03Handle boundaries honestlyPredict slot fallback, event retargeting, composed events, and realistic styling choices.
Build a new kind of element
Web Components are a set of browser features for building reusable interface pieces without a build step or a framework. You can teach the browser a new tag such as <cf-wc-counter>, give it a class, let it react when it enters or leaves the page, hide its internal markup in a shadow root, and leave openings where page content can show through.
They sit on top of skills from Class basics, Creating & changing elements, Bubbling & capturing, and Custom events. The new idea is the boundary: who owns the DOM, who styles it, and which events cross out.
HTML gives you built-in pieces: buttons, inputs, lists. Custom elements let you add one more piece to the box. Once the browser learns the name, every matching tag on the page is upgraded into your class.
- 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
- In JavaScript: The JavaScript class
- In real life: The factory learns that piece once
- In JavaScript:
customElements.defineregisters the name once - In real life: That piece snaps in anywhere
- In JavaScript: Existing tags upgrade when the definition loads
Where the analogy stops: A LEGO piece is physical and cannot run code. A custom element has JavaScript behavior, lifecycle callbacks, attributes, properties, and events.
A Web Component is usually a custom element plus optional Shadow DOM, templates, slots, and event conventions that make it reusable on ordinary web pages.
Custom elements and lifecycle
INTERACTIVEA custom element starts with a class that extends HTMLElement. Its name must start with a lowercase letter and contain a hyphen, such as profile-card. The hyphen is not decoration; it prevents collisions with future built-in HTML elements.
Registering is permanent for the page. If you call customElements.define("cf-wc-counter", Counter) twice, the second call throws a NotSupportedError. Lessons on this site may be revisited through client navigation or re-run by React Strict Mode, so every demo defines only on the client and first checks customElements.get(name).
Use lesson-prefixed names, guard every definition, and create the element inside an empty ref container React does not manage. React can render the container; the custom element owns the children inside it.
The browser sends notices to the element. “You are in the document now.” “You were removed.” “This watched attribute changed.” Your class decides how to update in response.
- In real life: A tenant moves in
- In JavaScript:
connectedCallbackruns - In real life: A tenant moves out
- In JavaScript:
disconnectedCallbackruns - In real life: A doorbell wired to one apartment
- In JavaScript:
attributeChangedCallbackfor names inobservedAttributes
Where the analogy stops: A landlord can notice many things informally. A custom element only gets lifecycle calls the platform defines, and attribute changes only ring the bells you listed.
Try the counter. Set an attribute, set the reflected property, click the shadow button, then read both back. The log shows real constructor, connection, and attribute changes from a browser element.
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.
Lifecycle order: create, connect, move, remove
STEP THROUGHLifecycle order matters because many components fetch data, attach listeners, or measure layout when connected, and clean up when disconnected. The constructor should set up internal state, not assume the element is already in the document. connectedCallback can run more than once for the same element if it is removed and re-appended.
The replay below is a pure model of the DOM order so it can be tested in Node. We also verified the same callback order in headless Chrome. Change the setting and predict whether the attribute callback appears before or after the first connection.
Choose when the attribute is set, then step through the pure lifecycle model. The order mirrors the browser behavior verified for this lesson.
script
el.setAttribute("count", "2");left.append(el);right.append(el);el.remove();left.append(el);Shadow DOM: a boundary for internals
INTERACTIVEShadow DOM gives an element a private-ish tree for its internals. Page CSS selectors do not normally reach in, and styles inside the shadow root do not leak out. The host still exists in the outer document, so you can place it, set attributes, listen for composed events, and pass custom properties to it.
A snow globe has a scene inside. Outside dust does not smear across the miniature trees, and the miniature snow does not spill onto your desk. But the globe still sits on the desk and can have knobs you choose to expose.
- In real life: The scene inside the globe
- In JavaScript: Shadow-root internals
- In real life: The glass boundary
- In JavaScript: Style and DOM encapsulation
- In real life: Openings in a picture frame
- In JavaScript: Slots showing host children
- In real life: Knobs the maker exposes
- In JavaScript:
::part()styling hooks
Where the analogy stops: The glass is not a vault. Closed shadow roots hide element.shadowRoot, but they are not a security boundary and do not protect secrets.
Toggle open and closed mode. Notice that a page paragraph rule colors the page paragraph but not the shadow paragraph. Then change the custom property: that value is an intentional bridge through the boundary. The demo also includes :host, ::slotted(), and part.
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 exposes shadowRoot. Page paragraph styles stay outside.
::slotted(strong), and part=label. Closed mode hides shadowRoot but is not a security boundary.mode: "open" lets scripts read element.shadowRoot. mode: "closed" makes that property return null. Closed mode can discourage casual coupling, but it is not a security feature.
Templates and slots
INTERACTIVEA <template> stores inert markup. The browser parses it, but it does not render until you clone template.content. Components use templates to give each instance the same internal structure without rebuilding strings by hand.
Slots are how light DOM children appear inside a shadow layout. A named slot receives children with the matching slot attribute. The default slot receives the rest. Fallback content appears only when no matching children are assigned. ::slotted() matches only top-level slotted nodes, not grandchildren.
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>`;Pure model says title: Ada | default: Writes tools.
Styling and events across the boundary
INTERACTIVEBoundaries should be deliberate. For styling, use CSS custom properties for theme values, ::part() for named internal pieces, and slots for page-owned content. For events, remember two words: retargeted and composed.
Inside the shop, the event starts at a particular button. Outside, the shop counter is the visible source. If the event is not composed, it stays inside the shop.
- In real life: A question starts inside the shop
- In JavaScript: An event fires inside a shadow root
- In real life: The counter answers for the shop
- In JavaScript: Outside listeners see the host as
event.target - In real life: The shop lets the question out
- In JavaScript:
composed: truelets a custom event cross
Where the analogy stops: A real shop has no DOM event path. The analogy is only about the private boundary and whether an event may cross it.
shadowButton.addEventListener("click", (event) => { console.log("inside target:", event.target);}); document.addEventListener("click", (event) => { console.log("outside target:", event.target); console.log(event.composedPath().map(nodeName));}); shadowButton.dispatchEvent(new CustomEvent("secret", { bubbles: true, composed: false,}));The custom event bubbles inside the shadow root, then stops at the boundary because composed is false.
Now sort common design needs by where they belong. This is the daily architecture question in Web Components: what should the page own, what should the component own, and what should be an explicit bridge?
- The page supplies the card title text
- A private increment button the component owns
- A component shows whatever paragraph the page put inside it
- Let page CSS style only the exposed label knob
- The actual input value submitted with a form
- Repeated internal wrapper copied for each instance
- Default text shown until the page provides children
- A chosen border element the page may color
Sort each need into light DOM, Shadow DOM, slot, or part. Every explanation describes the ownership boundary.
Where you will use this
Web Components shine when the component must survive outside one app framework: design-system buttons used by several teams, documentation widgets embedded in static pages, a map picker that plain HTML pages can use, or a small dashboard card shared between a React page and a server-rendered page.
They are not magic. If a widget is only used inside one React app, a React component can be simpler. If the page must submit native form controls directly, light DOM may beat Shadow DOM. If outside CSS needs full control, expose a small set of parts rather than letting consumers depend on internal class names.
| Piece | Owns | Main job | Common trap |
|---|---|---|---|
| Custom element | The host tag and class | Adds a new HTML element with lifecycle | Defining the same name twice |
| Shadow DOM | Internal DOM and styles | Encapsulates structure and selectors | Treating closed mode as security |
| Template | Reusable inert markup | Clones structure for each instance | Forgetting it does not render by itself |
| Slot | Placement of host children | Projects light-DOM content | Expecting ::slotted() to style grandchildren |
| Part | Named internal styling hook | Lets consumers style chosen internals | Exposing too many internals |
Modern browsers also support declarative Shadow DOM with <template shadowrootmode="open">. Feature-detect before relying on it, for example by checking whether HTMLTemplateElement.prototype has the relevant shadow-root property in the browser you target.
Common misconceptions
“A custom element must use Shadow DOM.”
No. A custom element can render light DOM, use Shadow DOM, or do almost nothing. The features compose but are separate.
“Closed shadow roots are secure.”
Closed mode hides element.shadowRoot from normal code. It does not protect secrets, permissions, or business logic.
“Any attribute change calls attributeChangedCallback.”
Only names returned from observedAttributes call it. If a name is missing, the callback stays silent.
“Slots move nodes into the shadow root.”
Slotted nodes remain light-DOM children of the host. The slot controls where they appear visually.
“All events leave a shadow root.”
Most user-interface events, like clicks, are composed. Many others are not. Custom events need composed: true when outside listeners should receive them.
“Page CSS can style anything with a clever selector.”
Shadow DOM blocks normal selectors. Components expose intentional styling hooks with custom properties, slots, and parts.
Practice: build the boundary muscle
5 EXERCISESDecide whether profile-card is a valid custom element name.
profile-card is valid: it starts lowercase and contains a hyphen. profilecard is invalid because it has no hyphen.
What is the first callback in the simplified program?
console.log("constructor");
console.log("attributeChangedCallback");
console.log("connectedCallback");The first log is constructor. A real custom element is constructed before observed attribute changes or connection callbacks can run.
A count property setter calls setAttribute("count", String(value)). What string is written for value 5?
const oldAttribute = "2";
const next = 5;
console.log(String(next));const oldAttribute = "2";
const next = 5;
console.log(String(next));Reflecting the property writes the attribute string 5. The component can parse it back when reading.
You see NotSupportedError after navigating away and back. Write the guard you would add around customElements.define.
const name = "cf-wc-profile";
if (!customElements.get(name)) {
customElements.define(name, ProfileCard);
}Guard the permanent registry before defining. This avoids NotSupportedError when the page is revisited or an effect re-runs.
Sketch where each piece belongs for a reusable product card: product title, internal button layout, theme color, and a save event.
A good design: page-owned name and price as light DOM children assigned to named slots; internal layout and a save button in Shadow DOM; part=button or part=price only for styling hooks you promise to support; a composed custom event for product-save.
Quiz: check your understanding
8 QUESTIONSAnswer once, then read every explanation. The wrong answers name the traps professionals still hit.
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 defined twice on one page?
Choose an answer to see the explanation.
Question 3 of 8Which callback reacts to an observed attribute change?
Choose an answer to see the explanation.
Question 4 of 8What does this print when
countreflects throughsetAttribute?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 shadow mode, what does
element.shadowRootreturn?Read the code, then predictconsole.log(null);Choose an answer to see the explanation.
Question 6 of 8What can
::slotted(p span)style?Choose an answer to see the explanation.
Question 7 of 8What does an outside click listener see for a click inside an open 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
- Custom element names need a lowercase start and a hyphen, and definitions are permanent for the page.
- Use
connectedCallback,disconnectedCallback, andattributeChangedCallbackfor lifecycle work; observed attributes are opt-in. - Shadow DOM encapsulates internal DOM and styles. Open versus closed changes the API, not security.
- Templates clone inert markup; slots project host children;
::partand custom properties expose deliberate styling hooks. - Events crossing a shadow root are retargeted, and custom events need
composed: trueto cross.
Remember the one-liner.
A Web Component teaches the browser a reusable tag, owns its internals, and chooses exactly which content, styles, and events cross its boundary.
Up next: The iteration protocols.