Web components and shadow DOM
The browser's own component model: custom HTML tags with their own lifecycle, a shadow tree that keeps their markup and styles private, and slots for the page's content, so one component works in React, Vue, Angular, or plain HTML.
The problem web components solve
Picture a company with three web apps: a React store, an Angular admin panel, and a help center in plain HTML from a content system. The design team wants one date picker, one button, and one toast across all three.
With framework components, that means writing the date picker three times. Each copy drifts: a focus bug gets fixed in React but not in Angular. When the store later moves to a new framework, the picker is written a fourth time.
There is a second problem. When the help center page loads the date picker, its CSS lands in the same global stylesheet as everything else. A rule like .header button { padding: 0 } from the content system squashes the picker's buttons. And the picker's own .day class collides with an article that also uses .day.
What web components are
Web components are a set of browser standards that let you define your own HTML tags. There is no library to load: the browser itself knows how to create, update, and remove the element. Three standards do the work, and you can use each one on its own.
- Custom elements: you register a class for a tag name like
date-picker. Every time that tag appears in the page, the browser creates an instance of your class and calls your code when it is added, removed, or its attributes change. - Shadow DOM: the element gets a private, hidden DOM tree (the shadow root). Page CSS can't select into it, and its CSS can't leak out. Ids inside it don't clash with ids in the page.
- HTML templates and slots: a
templateholds inert markup to clone, and aslotis a hole in the shadow tree where the page's own children show up.
Because the result is just an HTML element, every framework can render it. React, Vue, Angular, Svelte, and a static HTML page all know how to put a tag on the page, set attributes, and listen for events. That is the whole promise: write the component once, at the level of the platform, and use it everywhere.
Who uses them
This isn't a niche idea. GitHub, Shopify's Polaris, Salesforce, Adobe, Microsoft, and SAP all ship UI as custom elements, as chapter 8 shows. Embeds are the other big use: a payment button, chat widget, or map that a third party drops into your page is often one script tag plus one custom tag, with shadow DOM keeping its styles from fighting yours.
What they are not
Web components are not a replacement for a framework. They give you a component boundary, a lifecycle, and style isolation. They don't give you a reactive state system, a router, data fetching, or efficient re-rendering of a list. Most teams that build many web components use a small library like Lit or Stencil for those parts, and most apps still use a framework to compose them.
They are also not a security boundary. A shadow root hides markup from CSS selectors and document.querySelector, but any script in the same page can still reach inside. For real isolation from code you don't trust, you still need an iframe.
Check your understanding · Exercise 1
Which problems do web components solve on their own, without any library? Select all that apply.
The mental model: a sealed box with a labelled front panel
Think of a web component as a sealed appliance, like a microwave. The front panel has buttons, a display, and a door: that's the public API. The wiring inside is sealed: you can't reach it, and the kitchen's mess can't get into it. The maker can rewire the inside in the next model, and as long as the front panel stays the same, nobody's kitchen breaks.
A custom element works the same way. Its inside is the shadow tree, with its own markup and CSS. Its front panel is a short, fixed list of channels. Learn these channels and you understand every web component you will ever meet.
The six channels through the front panel
| Channel | Direction | Carries | Example |
|---|---|---|---|
| Attributes | page to element | strings only, written in HTML | size="large", disabled |
| Properties | page to element | any JavaScript value | el.items = [{ id: 1 }] |
| Events | element to page | a CustomEvent with a detail payload | rating-change with { value: 4 } |
| Slots | page to element | the page's own child elements, shown in a hole | a span slot="title" child |
| CSS custom properties | page to element | theme values that inherit through the boundary | --brand: #0a6 |
| Parts | page to element | permission to style one named inner node | rating-stars::part(star) |
Notice the shape: five channels go in, one comes out. Data flows down as attributes and properties, and news flows up as events. This is the same props-down, events-up rule you know from React and Vue, which is why web components fit inside those frameworks at all.
value and the slotted child go in through HTML, reviews through script, --rs-star-color through CSS, rating-change comes out, and ::part is the one styling door the author opened.Three trees, not one
The second idea to hold is that a page with web components has more than one DOM tree. The light DOM is the normal tree you write in HTML, including the children you put inside the custom element. The shadow DOM is the private tree the component attaches to itself. The browser renders a third, the flattened tree: the shadow tree with each slot replaced by the light DOM children assigned to it.
The light children never move. They stay children of the host element in the light DOM, page CSS still styles them, and document.querySelector still finds them. They are only *shown* inside the slot. Most confusion about slots comes from forgetting this.
A third idea: the browser draws a line called the shadow boundary between the host and its shadow root. Most things stop at the line (selectors, ids, querySelector, label for). A few things cross it on purpose (inherited CSS like font and color, custom properties, events marked composed, and the focus order). Chapters 4 and 5 go through each one.
Check your understanding · Exercise 2
A product-card component needs a list of 20 variant objects, the page's brand color, and a way to tell the page "add to cart was clicked". Which channels fit? Select all that apply.
Custom elements, step by step
Let's build a small rating-stars element: it shows five stars, takes a value, and tells the page when the user picks a new rating. Here is the whole class, then each part in turn.
class RatingStars extends HTMLElement {
static observedAttributes = ["value", "max"];
#value = 0;
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() { this.render(); }
attributeChangedCallback(name, oldValue, newValue) {
if (name === "value") this.#value = Number(newValue) || 0;
this.render();
}
get value() { return this.#value; }
set value(v) { this.setAttribute("value", String(v)); }
pick(n) {
this.value = n;
this.dispatchEvent(new CustomEvent("rating-change", {
detail: { value: n }, bubbles: true, composed: true,
}));
}
render() { /* fill this.shadowRoot with 5 buttons */ }
}
customElements.define("rating-stars", RatingStars);
Defining the element and naming it
customElements.define(name, Class) adds the tag to the page's custom element registry, a map from tag names to classes. The name must contain a hyphen (rating-stars, not rating), start with a lowercase letter, and not be one of a few reserved names like font-face. The hyphen guarantees no future built-in HTML tag will ever clash with yours, because built-ins never have one.
A name can be defined only once per registry. Calling define again with the same name throws a NotSupportedError. That matters as soon as two bundles on one page both include the same component: guard with if (!customElements.get("rating-stars")), and read chapter 9 on version clashes.
Upgrade: tags can arrive before their class
The HTML parser doesn't wait for your script. If the page contains rating-stars value="3" and the class loads 400 ms later, the browser first creates a plain, undefined element: it has its attributes and children, but no behavior and no shadow root. When define runs, the browser upgrades every matching element already in the page: it runs the constructor on it, then the callbacks.
Two consequences. First, the CSS selector rating-stars:not(:defined) matches the element before upgrade, so you can reserve its space and avoid a layout shift. Second, a framework may set a property on the element *before* the upgrade, which lands on the plain object and hides your setter. Lit, and hand-written components that follow the usual pattern, look for such a value when they upgrade, delete it, and re-apply it through the setter.
connectedCallback and disconnectedCallback can run many times as the element moves. Anything started on connect must be undone on disconnect.The lifecycle callbacks
constructor(): runs once per instance. Callsuper()first, set up state, attach the shadow root. Don't read attributes or add children here: during parsing they may not exist yet, and the spec forbids adding children to the host in the constructor.connectedCallback(): runs each time the element is inserted into a document. Render, add listeners onwindowordocument, start timers, open connections. It can run many times if the element is moved.disconnectedCallback(): runs each time the element is removed. Undo everythingconnectedCallbackstarted, or you leak listeners and timers (see the memory-leaks lesson). Moving a node fires disconnect then connect. The newermoveBefore()method can move it without that, if the element defines aconnectedMoveCallback; Chrome and Firefox support this, Safari not yet.attributeChangedCallback(name, old, new): runs when one of the attributes listed instatic observedAttributesis added, changed, or removed. Unlisted attributes don't trigger it: this keeps the browser from calling you for everyclassorstylechange.adoptedCallback(): runs when the element moves to another document, such as into aniframe. Rarely needed.
Attributes versus properties
This is the part people get wrong most often. An attribute lives in the HTML: it's always a string (or absent), and it shows in DevTools and in server-rendered markup. A property lives on the JavaScript object: it can be a number, array, object, or function.
Built-in elements link the two, which is why input.value and the value attribute feel the same. For your element, nothing links them unless you write the code.
The usual rule: expose every input as a property, and reflect the simple ones (strings, numbers, booleans) to and from an attribute, so that HTML authors and CSS selectors like rating-stars[value="5"] can use them. Rich data (arrays, objects, callbacks) stays property-only. Booleans follow the HTML convention: present means true, absent means false, so disabled="false" still means disabled.
| Question | Attribute | Property |
|---|---|---|
| Type | string or absent | any JavaScript value |
| Set from plain HTML | yes | no, needs script |
| Visible in server-rendered markup | yes | no |
| Usable in CSS selectors | yes | no |
| Good for | size, variant, disabled, value | items, config, formatter |
| Notify the element | observedAttributes plus the callback | a setter |
Events: the only way out
The element reports news with dispatchEvent(new CustomEvent(name, { detail })). Two flags decide how far it travels: bubbles: true lets it climb to ancestors, and composed: true lets it cross the shadow boundary. Without composed, an event fired inside the shadow root stops at the shadow root and the page never sees it.
An event dispatched on the host element itself, as in the class above, starts outside its own shadow root, so the page hears it even without composed. It still needs composed if the host sits inside another component's shadow root.
Name events in lowercase with hyphens, like rating-change. Avoid reusing built-in names like input or change unless you mean exactly the built-in meaning. Native input events from an inner text field are composed and already reach the page, so listeners could not tell yours from theirs.
Check your understanding · Exercise 3
A live-clock element starts a setInterval and adds a resize listener on window. Where should each piece of code go? Select all that apply.
Shadow DOM and slots
this.attachShadow({ mode: "open" }) gives the element a shadow root: a document fragment that becomes the element's rendered content. Whatever you put in this.shadowRoot is drawn in place of the element's children. The element itself is called the host.
What the boundary seals
- Selectors: page CSS like
button { padding: 0 }doesn't match buttons inside the shadow root, and the component'sbuttonrule doesn't match the page's buttons. - Queries:
document.querySelector(".star")doesn't find nodes inside a shadow root. You have to go throughel.shadowRoot.querySelector, on purpose. - Ids: each shadow root has its own id space. Two components can both use
id="label"without clashing. The flip side: an id reference can't point across the boundary (chapter 6). - Event targets: an event from inside looks, to listeners outside, as if it came from the host. This is called retargeting.
Retargeting is worth a closer look. If a user clicks the third star, a click listener on document sees event.target equal to the rating-stars element, not the inner button. The page learns *that* the rating was clicked, not *how* the rating is built.
If you truly need the inner path, event.composedPath() returns it for open shadow roots. Analytics scripts that log event.target see only the host, which is usually what you want.
rating-stars was clicked, not which inner button. event.composedPath() still lists the inner nodes for open shadow roots.Open or closed
With mode: "open", el.shadowRoot returns the root, so page scripts, test tools, and DevTools extensions can reach inside. With mode: "closed", el.shadowRoot returns null and only the component keeps a reference.
Closed mode sounds safer but isn't a security feature: a script that runs before yours can patch attachShadow and keep the root. It mostly makes testing and debugging harder. Use open unless you have a specific reason.
Slots: showing the page's children
Without slots, a shadow root hides the element's children completely. A slot element in the shadow tree marks where they should appear. A slot with no name is the default slot and takes every child that doesn't ask for a specific slot. A slot with a name, like slot name="title", takes only children that carry the matching attribute, slot="title".
<!-- inside the shadow root of info-card -->
<header><slot name="title">Untitled</slot></header>
<div class="body"><slot></slot></div>
<footer><slot name="actions"></slot></footer>
<!-- in the page (light DOM) -->
<info-card>
<h2 slot="title">Shipping update</h2>
<p>Your order left the warehouse.</p>
<p>Expected Thursday.</p>
<button slot="actions">Track</button>
</info-card>
The h2 goes to the title slot, the button to the actions slot, and both paragraphs to the default slot. The text Untitled is fallback content: it shows only if nothing is assigned to that slot. Children with a slot name that matches no slot aren't shown at all, which is a common silent bug when someone types slot="action" instead of slot="actions".
Remember that slotted nodes stay in the light DOM. The page's CSS styles them, the page's scripts own them, and a React parent can keep updating them. The component can listen for the slotchange event on a slot and call slot.assignedElements() to see what landed there, for example to hide an empty footer.
Focus and the boundary
Tab order flows through shadow roots as if the boundary weren't there: focusable nodes inside the shadow tree take their place in the page's sequence. When focus is inside, document.activeElement is the host and el.shadowRoot.activeElement is the real node. Passing delegatesFocus: true to attachShadow makes el.focus(), or a click on a part of the component that can't take focus, move focus to the first focusable node inside. That lets a custom input behave like a real one when its label or padding is clicked.
Check your understanding · Exercise 4
A page puts span slot="badge" inside a user-chip whose shadow root has only a default slot. What happens? Select all that apply.
Styling across the boundary
Style isolation is the main reason teams pick shadow DOM, and also the main source of complaints. "I can't change the padding on your button" is the most common issue filed against any web component library. The answer is a small set of deliberate doors, each owned by the component author.
What crosses and what doesn't
| Page CSS | Reaches inside the shadow root? | Why |
|---|---|---|
button { padding: 0 } | no | Selectors don't match across the boundary. |
body { font-family: Inter; color: #222 } | yes | Inherited properties flow down the flattened tree into the shadow root. |
:root { --brand: #0a6 } | yes, where the component uses var(--brand) | Custom properties inherit like color does. |
rating-stars::part(star) { color: gold } | yes, to nodes marked part="star" | The author opened that door. |
rating-stars { display: block; margin: 8px } | yes, on the host box | The host lives in the light DOM; page rules beat the component's :host rules. |
* { box-sizing: border-box } | no | Universal selectors stop at the boundary too, which surprises many teams. |
The last row trips people up. Global resets, utility classes like Tailwind's, and icon fonts set up with class names don't reach inside. A component author has to include whatever reset the inside needs, and a page that relies on utility classes can only style slotted children and the host.
Stopped at the boundary: p { color: red }, .title { color: blue }. The component's own CSS stays inside.
The author's selectors
:hoststyles the host element from inside, for example:host { display: inline-flex }. Custom elements aredisplay: inlineby default, which surprises people who expect a block. Page rules on the tag win over:hostrules, so the page can always override layout.:host([disabled])and:host(.compact)style the host when it matches a selector, which is how reflected attributes drive styling.::slotted(img)styles light DOM children assigned to a slot, but only the top-level assigned nodes, not their descendants, and the page's own rules win over it.part="label"on an inner node, plus::part(label)from outside, lets the page style exactly that node.exportpartsforwards parts from a nested component so the page can reach two levels down.
Theming with custom properties
The scalable way to theme is design tokens as CSS custom properties. The component reads var(--rs-star-color, #f5a623), with a fallback, and the page sets --rs-star-color: gold once on :root or on any ancestor. One value now themes every instance, in every framework, and dark mode is a single rule that swaps the variables. This matches the token layer in the design systems lesson: tokens are the contract, components consume them.
Prefix every variable with the component or system name (--rs-, --p- for Polaris-style systems). Unprefixed names like --color will collide with some page's own variables, and because custom properties inherit, the page's value silently wins.
Getting the CSS into the shadow root
There are three ways. A style element inside each shadow root is simplest, but every instance carries its own copy of the CSS text; with 500 table cells that adds up, even though some browsers cache identical sheets. Constructable stylesheets fix this: create one CSSStyleSheet in JavaScript, call replaceSync(css), and assign it to shadowRoot.adoptedStyleSheets in every instance, so 500 instances share one parsed sheet. Lit does this for you.
A link rel="stylesheet" in the shadow root also works and is cached by the browser, but it can flash unstyled content while it loads.
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { display: inline-flex; gap: 4px; }
button { color: var(--rs-star-color, #f5a623); }
:host([disabled]) button { opacity: 0.4; }
`);
// in the constructor of every instance
this.shadowRoot.adoptedStyleSheets = [sheet];
Check your understanding · Exercise 5
A team's page sets * { box-sizing: border-box } and --brand: purple on :root. Inside the shadow root of rating-stars, a button uses background: var(--brand). Which statements are true? Select all that apply.
Server rendering, forms, and accessibility
Three platform gaps held web components back for years: they rendered nothing until JavaScript ran, they couldn't take part in forms, and screen reader relationships broke at the shadow boundary. The first two are now solved in every major browser. The third is partly solved and still being worked on.
Declarative shadow DOM for server rendering
Until 2024, a shadow root could be created reliably only by script. A server-rendered page arrived with empty component tags that painted nothing until the bundle ran, then jumped into place, which hurts Largest Contentful Paint and Cumulative Layout Shift.
Declarative shadow DOM lets the server write the shadow root in HTML. A template element with the shadowrootmode attribute, placed as the first child of the host, is turned by the parser into a real shadow root as the HTML streams in. No JavaScript is needed to paint it. The shadowrootmode form is supported in Chrome, Edge, Safari, and Firefox, and became Baseline in February 2024, when Firefox 123 added it.
<rating-stars value="4">
<template shadowrootmode="open">
<style>:host { display: inline-flex } button { color: gold }</style>
<button aria-label="1 star">★</button>
<!-- ... four more buttons, 4 shown as filled ... -->
</template>
</rating-stars>
<script type="module" src="/rating-stars.js"></script>
When the script loads, the element upgrades and finds this.shadowRoot already filled. The constructor should check for it and keep it instead of calling attachShadow again: that check is the web component version of hydration. Lit's SSR package and Stencil's hydrate output generate this markup for you.
Two gotchas. The parser only turns these templates into shadow roots during the initial page parse or with setHTMLUnsafe() and Document.parseHTMLUnsafe(); setting innerHTML leaves them as plain templates, on purpose, for safety. And styles must be repeated in every instance's template, since there's no declarative way to share a stylesheet yet, so HTML grows with each instance.
Form-associated custom elements
An input inside a shadow root is invisible to the surrounding form: it's not submitted, form.reset() ignores it, and the browser's validation doesn't see it. Form-associated custom elements fix this. Set static formAssociated = true and call this.attachInternals() in the constructor. The returned ElementInternals object is the element's private link to the browser's form machinery.
internals.setFormValue(value)sets what the form submits under the element'snameattribute.internals.setValidity({ valueMissing: true }, "Pick a rating", anchor)marks it invalid, soform.checkValidity()and:invalidwork and the browser shows the message nearanchor.formResetCallback(),formDisabledCallback(disabled), andformStateRestoreCallback(state)let it react to a form reset, a disabledfieldset, and the browser restoring form state, for example after a back navigation or an autofill.- A
label forthat points at the host now labels it, because the host itself is the form control.
Accessibility across shadow roots
Shadow DOM doesn't hide anything from assistive technology: screen readers read the flattened tree, so a real button inside a shadow root is announced as a button. Most accessibility bugs come from two other places.
First, the host's own role. If toggle-switch is the thing users focus, it needs role="switch" and aria-checked. Setting those as attributes on the host from inside the component is called sprouting, and it fights with authors who set their own values.
Instead, set defaults through ElementInternals: internals.role = "switch" and internals.ariaChecked = "true". These act as defaults that the page's own attributes can still override, and they don't clutter the markup.
Second, id references across the boundary. aria-labelledby, aria-describedby, aria-controls, and label for all point at ids, and ids don't cross shadow roots. A label in the page can't name an input inside a component, and a combobox's inner input can't reference a listbox that the page renders. The reference breaks silently and the screen reader announces an unlabelled field.
for, aria-labelledby, or aria-controls that points across a shadow boundary fails silently, so keep each relationship inside one tree.Today's workarounds: keep related pieces in the same tree (put both the input and its label inside the component and take the label text as an attribute or slot), make the host itself the control with ElementInternals, or use ARIA element reflection (el.ariaLabelledByElements = [labelNode]), which takes element references instead of ids and can point from a shadow tree out to the light DOM, though not into another shadow root. Element reflection has worked in every major browser only since 2025, so check your support targets before relying on it.
A proposal called reference target would let a shadow root declare which inner element receives id references aimed at the host, so label for="my-input" would reach the real inner input. Chrome has started shipping it, while Firefox and Safari still keep it behind flags, so treat it as not yet usable across browsers. Meanwhile, test every component with a real screen reader, because these failures are silent.
Check your understanding · Exercise 6
Your date-field component wraps an input in a shadow root. Users report that the date is missing from submitted forms and that a screen reader says "edit text" with no label. Which fixes help? Select all that apply.
Using web components from React, Vue, Angular, and Svelte
A custom element is just a tag, so every framework can render it. The differences are in two places: how the framework passes data (attribute or property?) and how it listens for custom events. Get those two right and the component feels native everywhere.
The question every framework must answer
When you write rating-stars value={4} items={list} in a template, the framework has to decide: call setAttribute("items", list), which turns the array into the string [object Object], or assign el.items = list, which passes the real array? Plain HTML has no choice: everything is an attribute. Each framework has its own rule.
| Consumer | Passing data | Listening for rating-change | Notes |
|---|---|---|---|
| Plain HTML and JS | attributes in markup; properties via script | el.addEventListener("rating-change", fn) | Rich data needs a line of script. |
| React 18 and earlier | always attributes, so objects become strings | no JSX support; use a ref and addEventListener in an effect | The reason wrapper packages like @lit/react exist. |
| React 19 | a property if the element has one with that name, else an attribute; false removes the attribute | onrating-change={fn} adds a listener for the name after on, case and dashes kept | Passes every Custom Elements Everywhere test. |
| Vue 3 | a property if the key exists on the element, else an attribute; force with .prop or .attr | @rating-change="fn" | Tell the compiler which tags are custom with isCustomElement. Before upgrade, false becomes the string "false". |
| Angular | [items]="list" always sets the property, even before upgrade; [attr.size] sets the attribute | (rating-change)="fn($event)" | Add CUSTOM_ELEMENTS_SCHEMA so unknown tags compile. |
| Svelte | a property if it exists on the element, else an attribute | an event handler attribute or directive, depending on version | Good support since Svelte 3. |
React 19's change was the big one, since React is the most common host. Before it, a React app had to wrap each element in a small component that set properties and attached listeners through a ref. With React 19 you can usually drop the wrapper.
One subtlety: React decides property or attribute by checking whether the property exists on the element *when it sets the value*. If the element hasn't been defined yet, a rich value is set as an attribute and lost, and React sends it again only when that prop changes. So import and define your elements before React renders them.
React 18: reviews fails (string, data lost); compact fails ("false" means on); event fails (never runs).
Events and naming
Event names should be lowercase with hyphens. Some frameworks lowercase attribute names in templates or need special syntax for camelCase events, so ratingChange works in some hosts and not others. rating-change works everywhere. Put the payload in detail and keep it plain data, not class instances, so every host can read it.
Server rendering in frameworks
On the server, React 19 renders primitive props (strings, numbers, true) as attributes and drops objects, functions, and false, since only strings fit in HTML. It doesn't render the shadow root, so unless a tool emits declarative shadow DOM, the component paints only after its script runs; reserve its space with :not(:defined) meanwhile.
Types and metadata
TypeScript doesn't know your tags. Declare them by extending HTMLElementTagNameMap for DOM code and the JSX intrinsic elements for React. The Custom Elements Manifest, a JSON file (custom-elements.json) describing every tag's attributes, properties, events, slots, parts, and CSS variables, lets tools generate these types, framework wrappers, editor autocomplete, and Storybook docs from one source. Treat it as the published API surface, as the component API design lesson recommends for any component library.
Check your understanding · Exercise 7
A React 18 app renders data-table rows={rows} and the table shows nothing. After an upgrade to React 19, it works. Why? Select all that apply.
Libraries and real products
The raw APIs are low level on purpose. Hand-writing rendering and attribute reflection for 60 components means writing a small framework, badly. So most teams use a thin library whose output is still a standard custom element.
Decision How should a team build its web components?
What we pick: For a design system that must work in several frameworks, Lit is the common default: small, close to the platform, and widely used. Pick Stencil if generated framework wrappers and lazy loading matter more than a minimal toolchain. Write vanilla elements only for a handful of small, mostly behavioral pieces. Whatever you pick, the output should be plain custom elements, so consumers never depend on your library choice.
How real products use them
- GitHub uses many small vanilla and Catalyst-based custom elements to add behavior to server-rendered HTML: the page works as HTML first, and elements like
relative-timeenhance it after upgrade. - Shopify Polaris made web components its default in 2025 and put Polaris React into maintenance mode, so apps built with React, Vue, or no framework all get the same components loaded from Shopify, as the design systems lesson describes.
- Salesforce Lightning Web Components runs a large enterprise platform on custom elements, with its own compiler and a synthetic shadow DOM polyfill that many of its older components still rely on.
- Adobe (Spectrum Web Components), Microsoft (Fluent web components), SAP (UI5 Web Components), and Ionic ship their design systems as custom elements for the same reason: their customers use every framework.
- Embeds: chat widgets, video players, payment buttons, and map embeds increasingly ship as one custom element tag plus one script.
The pattern: web components win where one team serves many teams or customers whose stacks it doesn't control. Inside a single app on one framework, framework components usually stay simpler.
Embeddable widgets and micro frontends
Suppose you run a support chat company. Thousands of customers paste your widget into WordPress, Next.js, and Angular sites whose CSS, frameworks, and release schedules you don't control. The widget must look right everywhere and never break the host page.
What a custom element embed looks like
<script type="module" async
src="https://cdn.chatco.example/widget/v3/chat-widget.js"></script>
<chat-widget account="acme-42" position="bottom-right"
style="--chat-accent: #0a6"></chat-widget>
The host adds one script and one tag. Configuration goes in attributes, theming in custom properties, and the host listens for events like chat-open for its own analytics. Shadow DOM keeps the host's button styles off your buttons and your styles off theirs. The script URL carries a major version, so the host opts into breaking changes.
Custom element or iframe?
The key fact: a custom element runs in the same JavaScript world as the host page. Your script can read the host's cookies (those not marked HttpOnly), its DOM, and its storage, and the host's scripts can read yours. For a chat widget that's fine. For a payment form that collects card numbers, it's not: you must keep the host page's scripts away from the card field, and only a cross-origin iframe does that.
Decision How should a third party ship an embeddable widget?
What we pick: Use a plain custom element for UI that handles no secrets and benefits from fitting in: chat launchers, review stars, video players. Use an iframe, or a custom element wrapping one, whenever the widget handles credentials, payments, or personal data, or when you can't trust the host and the host can't trust you. The clickjacking and iframes lesson covers how iframes are protected with frame-ancestors, and the third-party scripts lesson covers the performance budget either way.
Web components as the micro frontend seam
The micro frontends lesson lists web components as one way to compose independently deployed apps in the browser. Each team ships its feature as a custom element (checkout-panel, reco-carousel), and the shell renders the tags. The element is the contract: attributes and properties in, events out. Teams can even use different frameworks inside their elements, at the cost of loading each framework.
The version clash problem
The custom element registry is global to the page. If the checkout team bundles ds-button from design system v4 and the search team bundles v5, the first one to call define wins. The second either throws NotSupportedError or, with a guard, silently uses the wrong version. Version 5's markup then runs against version 4's class.
- Load shared components once, from one URL owned by the platform team, and treat them like a shared dependency with a single version on the page.
- Put the major version in the tag name (
ds5-button) when two majors must coexist. Ugly, but it works today. - Scoped custom element registries, a newer standard, let a shadow root have its own registry so two versions can share a tag name in different subtrees. Safari 26 and Chrome 146 ship it and Firefox is still working on it, so check support before depending on it.
Check your understanding · Exercise 8
A bank wants to let partner sites embed a "check your loan rate" form that asks for income and a national ID number. Which designs are sound? Select all that apply.
Failure modes, when not to use them, and interviews
What breaks in production
| Symptom | Cause | Fix |
|---|---|---|
| Content jumps when the page finishes loading | Undefined elements render as empty inline boxes until upgrade | Reserve space with my-el:not(:defined) { display: block; min-height: 48px }, or server render with declarative shadow DOM |
NotSupportedError: this name has already been used | Two bundles define the same tag | Load shared elements once; guard with customElements.get; version tag names |
A rich prop arrives as [object Object] | The host set an attribute instead of a property, or set it before upgrade | Use React 19 or a wrapper; define elements before the host renders; re-apply properties set before upgrade |
| The page never hears an event | Fired inside the shadow root without composed: true | Dispatch on the host, or set bubbles and composed |
| Memory grows on route changes | Listeners or timers started in connectedCallback never stopped | Undo everything in disconnectedCallback; use an AbortController signal for listeners |
| Screen reader says "unlabelled" | aria-labelledby or label for points across a shadow boundary | Keep label and control in one tree, or use ElementInternals and element reflection |
| Tests can't find elements | Test queries don't pierce shadow roots | Use tools that do (Playwright locators pierce open roots), or query shadowRoot explicitly |
When not to use them
- A single app on a single framework. Framework components share state, context, and rendering with the rest of the app. Wrapping them as custom elements adds a boundary with no one on the other side.
- Highly interactive, data-heavy screens. A dashboard with shared state across 40 widgets wants one reactive system, not 40 elements passing events.
- SEO-critical content that you can't server render. If your stack can't output declarative shadow DOM, content inside shadow roots arrives late. Keep content in the light DOM and use the element for behavior only.
- Untrusted code. Shadow DOM isolates styles, not scripts. Use an iframe.
- Styling-heavy pages built on utility classes. Utility CSS can't reach into shadow roots. Either skip shadow DOM for those pieces (a custom element can render into its light DOM) or accept the parts and variables contract.
The last point hints at a quieter option: custom elements without shadow DOM. An element that adds behavior to its own light DOM children, like GitHub's, keeps page styles working, is easy to server render, and has no cross-root accessibility issues. You lose style isolation, which inside your own app you often don't need.
Using this in a system design interview
Bring web components up when the prompt has a cross-framework or cross-company boundary: "design a component library for 15 teams on three frameworks", "design an embeddable reviews widget", "design a checkout that other teams' apps host", or a micro frontends question. Don't bring them up for a single React app, where they signal tool-chasing.
- Draw the layers: host apps on top, the custom element layer in the middle, tokens as CSS custom properties underneath.
- State the contract: attributes for simple config, properties for rich data, events out, slots for content, parts and variables for styling.
- Say how it renders on the server (declarative shadow DOM or a sized placeholder) and how it joins forms (
ElementInternals). - Name the risks: version clashes in the global registry, accessibility across shadow roots, and that shadow DOM isn't a security boundary.
- For embeds, say where you'd switch to an iframe and why.
By level: a junior answer knows the three standards and that shadow DOM scopes CSS. A mid-level answer explains attributes versus properties, events with composed, slots, and theming with custom properties. A senior answer adds server rendering, form association, cross-root accessibility, React 19's behavior, registry version clashes, and the custom element versus iframe call for embeds, each with a reason.
Check your understanding · Exercise 9
Which situations are a good fit for web components? Select all that apply.
Summary
- Web components are three standards: custom elements (your own tags with a lifecycle), shadow DOM (a private tree with scoped CSS and ids), and templates with slots (holes where the page's children show).
- The public API has six channels: attributes, properties, events, slots, CSS custom properties, and parts. Everything else is private.
- Reflect simple values between attributes and properties; keep rich data as properties; send news out with lowercase hyphenated
CustomEvents,composedwhen fired inside the shadow root. - Clean up in
disconnectedCallbackwhateverconnectedCallbackstarts, and handle properties set before the element upgrades. - Declarative shadow DOM (
shadowrootmode, Baseline 2024) server renders shadow roots;ElementInternalsmakes custom elements real form controls and gives them default ARIA semantics. - Id-based ARIA relationships break across shadow roots; design around it today and treat reference target as future.
- React 19 passes properties and custom events natively; Vue, Angular, and Svelte already did. Load shared elements once per page to avoid registry clashes.
- Shadow DOM isolates styles, not scripts: use custom elements for embeds that hold no secrets and iframes for anything sensitive.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Your company has apps in React, Angular, and a legacy server-rendered stack. How would you build one shared component library?
Build the leaf components (button, input, date picker, dialog) as custom elements, probably with Lit, because every stack can render a tag, set properties, and listen for events.
Define a contract per component: attributes for simple config, properties for rich data, lowercase hyphenated events with plain detail, named slots for content, and a styling surface of prefixed CSS custom properties and parts. Publish it as a Custom Elements Manifest and generate types and thin framework wrappers from it.
Ship design tokens as CSS custom properties so theming and dark mode work through shadow roots. Make inputs form-associated with ElementInternals so they submit with native forms.
Load the library once per page from a shared, versioned URL to avoid registry clashes, and server render with declarative shadow DOM where the stack supports it, with sized :not(:defined) placeholders elsewhere.
Question 2
What is the difference between attributes and properties on a custom element, and why does it matter for React?
Attributes are strings in the HTML; properties are JavaScript values on the object. A custom element links them only if its author writes the reflection code.
Simple values should exist as both and reflect, so HTML authors and CSS selectors can use them. Arrays, objects, and functions should be properties only.
React 18 and earlier set every prop on a custom element as an attribute, so arrays became [object Object] and custom events needed refs. React 19 assigns a property when the element has one and supports on props for custom events, so wrappers are usually no longer needed. Values set before the element is defined still need care.
Question 3
Does shadow DOM make a third-party widget secure?
No. Shadow DOM scopes CSS, ids, and selector queries, but the widget's script and the host's scripts share one JavaScript world. Either can read the other's DOM, non-HttpOnly cookies, and storage, and closed shadow roots can be bypassed by a script that patches attachShadow first.
For anything handling secrets, like card numbers or identity documents, use a cross-origin iframe, optionally wrapped in a custom element for a nice API, and protect it as the clickjacking lesson describes.
Question 4
A web component library works in the browser but your server-rendered pages show layout shift and empty boxes until JavaScript loads. What do you do?
Server render the shadow roots with declarative shadow DOM, a template shadowrootmode="open" inside each host, which the parser turns into a real shadow root before any script runs. Lit and Stencil can generate it.
Make the constructor reuse an existing shadowRoot instead of attaching a new one, which is the hydration step.
Where server rendering isn't possible, reserve space with :not(:defined) rules sized to the final component, and keep important content in the light DOM through slots so it's present in the HTML.
Question 5
Screen reader users say a custom combobox has no label and doesn't announce its options. What's likely wrong?
Id references are probably crossing a shadow boundary: a page label for aimed at an inner input, or an inner input's aria-controls aimed at a listbox in a different tree. Ids don't resolve across shadow roots, and the failure is silent.
Fixes: keep the input, label, and listbox in the same shadow tree and accept the label as an attribute or slot; or make the host the form control with ElementInternals so page labels apply to it; or use ARIA element reflection where supported. The reference target proposal may help in future, but don't rely on it yet.
Then verify with a real screen reader, since automated checkers often miss cross-root breaks.
Practice it
These design problems lean on web components and shadow dom.
References
- MDN: Web Components The starting point for custom elements, shadow DOM, and templates.
- MDN: Using custom elements Lifecycle callbacks, observed attributes, and customized built-ins.
- MDN: ElementInternals Form association, validity, and default ARIA semantics.
- web.dev: Declarative Shadow DOM Server rendering shadow roots and hydrating them.
- HTML Standard: Custom elements The normative rules for names, upgrades, and reactions.
- Custom Elements Everywhere How well each framework passes properties and listens for events.
- React: custom HTML elements How React 19 chooses between properties and attributes and listens for custom events.
- Lit documentation The most widely used library for building web components.
Read next
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Micro-frontendsSplitting one web app into parts that separate teams build and deploy on their own, then combining them at build time, on the server, or in the browser: how each approach works, what it costs in bytes and complexity, and why most teams should not do it.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.