Web Components: custom elements, shadow DOM and where they fit
What does the browser actually give you with custom elements and shadow DOM, and when are they the right building block?
Web Components are three platform features used together: custom elements (your own tag with lifecycle callbacks), shadow DOM (a scoped subtree for markup and CSS) and templates. They give you framework-neutral UI primitives, at the cost of doing state, rendering and SSR yourself or with a thin library such as Lit.
THE MENTAL MODEL
A custom element is a class the browser instantiates whenever it meets your tag, before or after you register it; registration upgrades existing nodes in place. Shadow DOM is a second, private DOM tree hanging off that element: page CSS and querySelector stop at its boundary, while slots project the element's own children (the light DOM) into chosen places. Only a few things cross the boundary on purpose: inherited properties and CSS custom properties flow in, ::part exposes named internals, and composed events flow out. Everything else (reactive state, templating, data flow) is not part of the platform, which is why the interface you design for a component is attributes and properties in, events out, and slots for content.
HOW IT FITS TOGETHER
- Parsed or created, undefined<ds-badge> in markup or createElement before define(): a plain HTMLElement, matched by :not(:defined).
- customElements.define()Name must contain a hyphen. The registry maps the tag to the class; customElements.whenDefined() resolves.
- Upgrade: constructor()Runs for every existing instance. Call super(), attach shadow root, set defaults. Do not read attributes or children here.
- attributeChangedCallbackFires for each attribute in static observedAttributes, including the initial parsed values, before connectedCallback.
- connectedCallbackInserted into a document. Start work here: listeners on window/document, fetches, observers. Can run many times.
- Moves and adoptionmoveBefore() calls connectedMoveCallback instead of disconnect + connect when defined. adoptedCallback runs on document change.
- disconnectedCallbackRemoved from the document. Undo everything connectedCallback started; the node may be reinserted later.
- Host element (light DOM)<ds-card variant="warn"> plus its children. Page CSS styles the host box; attributes and properties are the public API.
- Shadow rootattachShadow({ mode }) or <template shadowrootmode>. Scopes ids, selectors and querySelector. Closed mode hides it from outside JS, not from determined code.
- Slots<slot> and <slot name> project light DOM children. Projected nodes stay in the light DOM and keep page styles; ::slotted() styles only top-level ones.
- Shadow styles<style> or adoptedStyleSheets. :host and :host(.x) style the host from inside; page selectors never match shadow content.
- Deliberate boundary crossingsInherited properties (color, font) and CSS custom properties flow in; ::part(name) exposes internals; composed events flow out, retargeted to the host.
KEY TERMS
- Custom element registry
- customElements.define(name, class) is global per document by default and a name can be defined once; a second define throws. Scoped registries (new CustomElementRegistry() passed to attachShadow) avoid collisions between independently shipped components; check support before relying on them.
- Upgrade
- Unknown hyphenated tags are valid HTML. When the definition arrives, the browser runs the constructor on existing instances. Properties set before the upgrade land as own properties that shadow your class accessors, so re-apply them in connectedCallback (Lit does this for you).
- Attributes vs properties
- Attributes are strings in markup; properties are any JS value. observedAttributes plus attributeChangedCallback handles the former, getters/setters the latter. Reflect primitives both ways; keep objects and arrays property-only.
- Shadow DOM and slots
- The shadow tree renders instead of the host's children; slots decide where those children appear. Events from inside are retargeted to the host and cross out only if created with composed: true (most UI events already are).
- Styling across the boundary
- CSS custom properties are the theming API (var(--ds-accent, blue)); ::part(label) lets consumers style specific internals; :host and :host-context are for the component itself. Global resets and utility classes do not reach inside.
- Declarative Shadow DOM
- <template shadowrootmode="open"> inside the host makes the parser attach a shadow root with no JavaScript, so server-rendered components paint before scripts load. Baseline since 2024 (Chrome 111, Safari 16.4, Firefox 123). innerHTML ignores it; use setHTMLUnsafe or parseHTMLUnsafe.
- Form-associated custom elements
- static formAssociated = true plus attachInternals() gives the element a form value (setFormValue), constraint validation (setValidity), labels, reset and restore callbacks, and custom states (:state(name)). Without it, a shadow-DOM input is invisible to <form>.
IN YOUR STACK
JavaScript · Custom Elements + Shadow DOM Client
const template = document.createElement("template");
template.innerHTML = `
<style>
:host { display: block; border: 1px solid var(--ds-border, #ccc); padding: 1rem; }
:host([variant="warn"]) { border-color: var(--ds-warn, darkorange); }
</style>
<header part="header"><slot name="title">Untitled</slot></header>
<slot></slot>`;
class DsCard extends HTMLElement {
static observedAttributes = ["variant"];
#variant = "info"; #abort = null;
constructor() {
super();
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
}
attributeChangedCallback(name, _old, value) { if (name === "variant") this.#variant = value ?? "info"; }
connectedCallback() {
this.#abort = new AbortController();
this.shadowRoot.querySelector("header").addEventListener("click", () =>
this.dispatchEvent(new CustomEvent("card-select", { bubbles: true, composed: true, detail: { variant: this.#variant } })),
{ signal: this.#abort.signal });
}
disconnectedCallback() { this.#abort.abort(); }
}
customElements.define("ds-card", DsCard);- Clone one template per instance instead of setting innerHTML repeatedly; the parse cost is paid once.
- Treat connectedCallback as reentrant: an element moved with appendChild disconnects and reconnects, so tear down in disconnectedCallback (an AbortController keeps that symmetric).
- Usage: <ds-card variant="warn"><span slot="title">Heads up</span>Body</ds-card>. Consumers theme it with --ds-border and ds-card::part(header); they cannot reach the header any other way.
TypeScript · Lit 3 Client
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("ds-badge")
export class DsBadge extends LitElement {
static styles = css`
:host { display: inline-flex; }
button { font: inherit; color: var(--ds-badge-color, currentColor); }
`;
@property({ type: Number, reflect: true }) count = 0;
@property({ type: Number }) max = 99;
@state() private pressed = false;
render() {
const label = this.count > this.max ? `${this.max}+` : String(this.count);
return html`<button part="button" aria-pressed=${this.pressed} @click=${this.toggle}>
<slot></slot> ${label}
</button>`;
}
private toggle() {
this.pressed = !this.pressed;
this.dispatchEvent(new CustomEvent("badge-toggle", { detail: { pressed: this.pressed }, bubbles: true, composed: true }));
}
}
declare global { interface HTMLElementTagNameMap { "ds-badge": DsBadge } }- Lit is about 5 KB: reactive properties, batched async rendering and tagged-template DOM updates on top of the standard APIs. The output is still a plain custom element.
- These decorators need experimentalDecorators: true and useDefineForClassFields: false. With standard decorators, write @property() accessor count = 0.
- @lit-labs/ssr can render Lit components to Declarative Shadow DOM on the server; it is still a labs package.
HTML · Declarative Shadow DOM Client + server
<user-card>
<template shadowrootmode="open">
<style>:host { display: block } .name { font-weight: 600 }</style>
<p class="name"><slot name="name"></slot></p>
<button part="follow">Follow</button>
</template>
<span slot="name">Ada Lovelace</span>
</user-card>
<script type="module">
customElements.define("user-card", class extends HTMLElement {
#internals = this.attachInternals();
constructor() {
super();
// Reuse the server-rendered root; only build one on client-only renders.
let root = this.#internals.shadowRoot;
if (!root) {
root = this.attachShadow({ mode: "open" });
root.innerHTML = '<p class="name"><slot name="name"></slot></p><button part="follow">Follow</button>';
}
root.querySelector("button").addEventListener("click", () =>
this.dispatchEvent(new CustomEvent("follow", { bubbles: true, composed: true })));
}
});
</script>- The parser attaches the shadow root when it reaches the opening <template>, so the card is styled and laid out before any JavaScript runs.
- Only the first <template shadowrootmode> in a host counts. Repeat the <style> per instance (or link a shared stylesheet) because there is no shared declarative stylesheet yet.
- innerHTML and the DOMParser default path ignore shadowrootmode; use Element.setHTMLUnsafe() or Document.parseHTMLUnsafe() for streamed fragments.
JavaScript · ElementInternals Client
class RatingInput extends HTMLElement {
static formAssociated = true; static observedAttributes = ["value", "required"];
#internals = this.attachInternals(); #value = "";
constructor() {
super();
const root = this.attachShadow({ mode: "open", delegatesFocus: true });
root.innerHTML = [1, 2, 3, 4, 5].map((n) => `<button type="button" value="${n}">${n}</button>`).join("");
root.addEventListener("click", (e) => {
const picked = e.target.closest("button")?.value;
if (picked) { this.value = picked; this.dispatchEvent(new Event("change", { bubbles: true })); }
});
}
get value() { return this.#value; }
set value(next) {
this.#value = String(next ?? "");
this.#internals.setFormValue(this.#value); // submitted as name=value
const missing = this.hasAttribute("required") && !this.#value;
this.#internals.setValidity(missing ? { valueMissing: true } : {}, missing ? "Pick a rating" : "", this.shadowRoot.querySelector("button"));
this.#value ? this.#internals.states.add("rated") : this.#internals.states.delete("rated"); // :state(rated)
}
attributeChangedCallback(name, _old, next) { this.value = name === "value" ? next : this.#value; }
formResetCallback() { this.value = this.getAttribute("value"); }
formStateRestoreCallback(state) { this.value = state; }
}
customElements.define("rating-input", RatingInput);- Now <rating-input name="score" required> submits with the form, blocks submission when empty, reacts to <form>.reset() and works with <label for>.
- The third setValidity argument is the anchor the browser points its validation bubble at.
- ElementInternals is Baseline since 2023; custom states and the :state() selector reached all engines in 2024.
TypeScript (TSX) · React 19 Client
import { useState } from "react";
import type { DsBadge } from "@acme/ds/ds-badge.js";
import "@acme/ds/ds-badge.js"; // side effect: customElements.define("ds-badge", ...)
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"ds-badge": React.HTMLAttributes<DsBadge> & { count?: number; max?: number; ref?: React.Ref<DsBadge> };
}
}
}
export function InboxButton({ unread }: { unread: number }) {
const [pressed, setPressed] = useState(false);
return (
<ds-badge
count={unread} // assigned as a property: `count` exists on the element
max={99}
onbadge-toggle={(e: CustomEvent<{ pressed: boolean }>) => setPressed(e.detail.pressed)}
aria-describedby="inbox-hint"
>
Inbox {pressed ? "(pinned)" : ""}
</ds-badge>
);
}- React 19 passes all Custom Elements Everywhere tests: on the client, a prop that matches a property on the element instance is set as a property, otherwise as an attribute. Earlier versions stringified everything into attributes.
- Custom events use on + the exact event name, case-sensitive: onbadge-toggle listens for badge-toggle.
- During SSR only string, number and true props render (as attributes); objects, functions and false are dropped, so complex data arrives after hydration.
TypeScript + Vue SFC · Vue 3 + Vite Client
// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: {
// Only our design-system prefix; a blanket includes("-") also matches router-view.
compilerOptions: { isCustomElement: (tag) => tag.startsWith("ds-") },
},
}),
],
});
// InboxButton.vue
// <script setup lang="ts">
// import { ref } from "vue";
// import "@acme/ds/ds-badge.js";
// const unread = ref(3);
// const onToggle = (e: CustomEvent<{ pressed: boolean }>) => console.log(e.detail.pressed);
// </script>
// <template>
// <ds-badge :count="unread" :max="99" @badge-toggle="onToggle">Inbox</ds-badge>
// </template>- Vue checks key in element and sets a DOM property when one exists, otherwise an attribute. Force a property with :prop.prop or the .prop shorthand.
- Custom events arrive as native CustomEvent objects; read payloads from event.detail.
- Vue can also produce custom elements with defineCustomElement, which is how some teams ship one Vue codebase into non-Vue hosts.
TypeScript · Angular Client
import { Component, CUSTOM_ELEMENTS_SCHEMA, signal } from "@angular/core";
import "@acme/ds/ds-badge.js";
@Component({
selector: "app-inbox-button",
// Without this schema Angular rejects unknown elements and bindings at compile time.
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<ds-badge [count]="unread()" [max]="99" (badge-toggle)="onToggle($event)">
Inbox
</ds-badge>
`,
})
export class InboxButtonComponent {
unread = signal(3);
pinned = signal(false);
onToggle(event: Event) {
this.pinned.set((event as CustomEvent<{ pressed: boolean }>).detail.pressed);
}
}- [count] is a property binding; use [attr.count] when you specifically need the attribute.
- CUSTOM_ELEMENTS_SCHEMA turns off template checking for every hyphenated tag in this component, so typos in ds-* tags and inputs are no longer caught.
WHEN TO REACH FOR WHAT
Web Components (vanilla or Lit)
Use when A design system or widget must work across React, Vue, Angular and server-rendered pages, or survive framework migrations; or you ship an embeddable widget into pages you do not control.
Avoid when One framework owns the whole app: native components give better SSR, typing, state and tooling with less glue.
Framework components
Use when Single-framework product where SSR, streaming, data loading and type-checked props matter more than portability.
Avoid when Several teams on different frameworks need the same buttons and inputs; you would maintain parallel implementations.
Shadow DOM encapsulation
Use when Styles must survive hostile or unknown page CSS (third-party embeds, CMS pages) and consumers should theme only through tokens and parts.
Avoid when You rely on global utility CSS (Tailwind classes), page-level resets or third-party scripts that query into components; use light-DOM custom elements instead.
iframe embed
Use when You need a security boundary: untrusted code, separate origin, payments or auth UIs that the host page must not script.
Avoid when You only want style isolation; iframes cost a document, block shared focus, sizing and accessibility flow.
WHERE IT BITES
- Reading attributes or children in the constructor. During parsing they may not exist yet, and createElement forbids adding them; do it in attributeChangedCallback or connectedCallback.
- Expecting closed shadow roots to be a security boundary. They only hide this.shadowRoot from outside code; anything running in the page can still patch attachShadow.
- Dispatching events without composed: true from inside the shadow root: listeners outside never see them. With it, event.target outside is retargeted to the host.
- Defining the same tag twice when two bundles ship the same component version (micro-frontends, widgets). define() throws; guard with customElements.get() or use scoped registries.
- Putting form inputs inside shadow DOM without formAssociated: the value is not submitted, labels do not associate and validation is skipped.
- Assuming SSR works because the framework renders the tag. Without Declarative Shadow DOM output, the server sends an empty host and the content pops in after the definition loads.
CLOSE THE AI. EXPLAIN THIS.
A <ds-select> inside a React 19 page renders unstyled until scripts load, its options prop arrives as "[object Object]" in an older React, and its value is missing from form submissions. Name the platform feature that fixes each of the three.SOURCES
- MDN: Using custom elements (opens in new tab)Checked
- MDN: Using shadow DOM (opens in new tab)Checked
- web.dev: Declarative Shadow DOM (opens in new tab)Checked
- MDN: ElementInternals (opens in new tab)Checked
- React 19: Support for Custom Elements (opens in new tab)Checked
- Lit: Components overview (opens in new tab)Checked
Explainer reviewed