cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

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 template holds inert markup to clone, and a slot is 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.

Layered stack. Top: three host apps, the store in React, the admin panel in Angular, and the help center in plain HTML. Each points down to one shared layer of custom elements: ds-button, date-picker, and toast-stack, written once. Underneath sits the browser platform: custom elements, shadow DOM, and slots, with no library required.host apps, any stackstore: Reactadmin: Angularhelp: plain HTMLcustom elements, written onceds-buttondate-pickertoast-stackbrowser: custom elements, shadow DOM, slots
One implementation in the middle serves every stack on top, because each app only needs to render a tag, set properties, and listen for events. The bottom layer is the browser itself, so there is no runtime to ship or upgrade.

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

Everything else is private. A well-designed component documents these six and nothing more.
ChannelDirectionCarriesExample
Attributespage to elementstrings only, written in HTMLsize="large", disabled
Propertiespage to elementany JavaScript valueel.items = [{ id: 1 }]
Eventselement to pagea CustomEvent with a detail payloadrating-change with { value: 4 }
Slotspage to elementthe page's own child elements, shown in a holea span slot="title" child
CSS custom propertiespage to elementtheme values that inherit through the boundary--brand: #0a6
Partspage to elementpermission to style one named inner noderating-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.

Annotated markup of a rating-stars element. The value attribute is a string. A child b element with slot label is shown inside the component. The script line el.reviews equals an array sets a property that can hold any value. addEventListener for rating-change is the event, the only way out. A CSS rule on body sets the custom property --rs-star-color, which inherits into the shadow root. The selector rating-stars::part(star) styles one inner node the author exposed.<rating-stars value="4">attribute: a string <b slot="label">Food</b>slot: shown inside</rating-stars>el.reviews = [r1, r2]property: any valueaddEventListener("rating-change")event: the way outbody { --rs-star-color: gold }CSS variable: inheritsrating-stars::part(star)part: one inner node
Six channels and nothing else: 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.

js
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);
A complete custom element in about 25 lines, with no library.

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.

State machine of a custom element. Undefined: the tag is parsed but no class exists yet. When define runs, it becomes constructed: the constructor ran and the shadow root is attached. When inserted, it becomes connected: rendered and live. When removed, it becomes disconnected, with listeners removed, and re-inserting it makes it connected again. A note says the constructor runs once, connectedCallback on each insert, disconnectedCallback on each exit. Another note says an observed attribute change runs its callback while the element stays connected.undefinedno class yetdefine()constructedshadow attachedinsertedconnectedrendered, liveremoveddisconnectedlisteners removedre-insertedconstructor: onceconnected: each insertdisconnected: each exitattribute set:callback runs,stays connected
The constructor runs once, but 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. Call super() 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 on window or document, start timers, open connections. It can run many times if the element is moved.
  • disconnectedCallback(): runs each time the element is removed. Undo everything connectedCallback started, or you leak listeners and timers (see the memory-leaks lesson). Moving a node fires disconnect then connect. The newer moveBefore() method can move it without that, if the element defines a connectedMoveCallback; Chrome and Firefox support this, Safari not yet.
  • attributeChangedCallback(name, old, new): runs when one of the attributes listed in static observedAttributes is added, changed, or removed. Unlisted attributes don't trigger it: this keeps the browser from calling you for every class or style change.
  • adoptedCallback(): runs when the element moves to another document, such as into an iframe. 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.

QuestionAttributeProperty
Typestring or absentany JavaScript value
Set from plain HTMLyesno, needs script
Visible in server-rendered markupyesno
Usable in CSS selectorsyesno
Good forsize, variant, disabled, valueitems, config, formatter
Notify the elementobservedAttributes plus the callbacka 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's button rule doesn't match the page's buttons.
  • Queries: document.querySelector(".star") doesn't find nodes inside a shadow root. You have to go through el.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.

Sequence diagram with four lifelines: inner button, shadow root, host element, and document. The user clicks star 3, so the event starts at the inner button and reaches the shadow root. Because it is composed, it crosses to the host element. It bubbles to the document, where the target has become the host. A note says listeners outside see the host, not the inner button.inner buttonshadow roothost elementdocumentclick star 3composedtarget = hostlisteners outside see the host,not the inner button
This is retargeting: the page learns that 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".

html
<!-- 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 shadow template (top) and how a page uses it (bottom).

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".

Two trees. Left, the light DOM as written: info-card with three children, an h2 with slot title, a p, and a button with slot actions. Right, the flattened tree the browser renders: info-card contains header, div, and footer from its shadow root; the title slot under header shows the h2, the default slot under div shows the p, and the actions slot under footer shows the button. A note says the children stay in the light DOM and page CSS still styles them.light DOM, as writtencardh2pbtnchildren stay here,page CSS styles themflattened, as renderedcardhdrdivftrh2pbtnslots
Slotting changes where children are drawn, not where they live. The h2, p, and button are still light DOM children of the card, so page CSS and page scripts still reach them.

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 CSSReaches inside the shadow root?Why
button { padding: 0 }noSelectors don't match across the boundary.
body { font-family: Inter; color: #222 }yesInherited 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 boxThe host lives in the light DOM; page rules beat the component's :host rules.
* { box-sizing: border-box }noUniversal 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.

The user-card component
Page rules:
Page rules on the left, the user-card component on the right with a shadow root. h3 .title part=name gets serif font, gold. p gets serif font. button gets serif font, brand color. The page's own button is untouched by the component's CSS.page CSSp { color: red }body { font-family: serif }:root { --brand: teal }.title { color: blue }::part(name) { color: gold }user-card: shadow rooth3 .title part=nameserif font, goldpserif fontbuttonserif font, brand colorcomponent CSS: button roundthe page's own buttoncomponent CSS stays inside
With shadow DOM, only inherited properties (the font), custom properties (the brand color), and exposed parts reach inside, and the component's own rules stay inside too.

Stopped at the boundary: p { color: red }, .title { color: blue }. The component's own CSS stays inside.

The author's selectors

  • :host styles the host element from inside, for example :host { display: inline-flex }. Custom elements are display: inline by default, which surprises people who expect a block. Page rules on the tag win over :host rules, 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. exportparts forwards 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.

js
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.

html
<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.

Timeline from 0 to 1,100 ms for a server-rendered page. Script only: HTML arrives by 200 ms, then the component is an empty box until its JavaScript runs from 700 to 900 ms, and it paints at 900 ms. With declarative shadow DOM: HTML with the shadow root arrives by 250 ms and the component paints right away; the JavaScript from 700 to 900 ms only hydrates it, and it is live at 900 ms.script onlyHTMLempty boxJSpaintedwith DSDHTML+DSDpaintedJSlive0 ms500 ms1000 msfirst paint 650 ms sooner
The same page and the same script. With declarative shadow DOM the component paints with the HTML, about 650 ms earlier here, and the script only adds behavior later, so nothing jumps.

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's name attribute.
  • internals.setValidity({ valueMissing: true }, "Pick a rating", anchor) marks it invalid, so form.checkValidity() and :invalid work and the browser shows the message near anchor.
  • formResetCallback(), formDisabledCallback(disabled), and formStateRestoreCallback(state) let it react to a form reset, a disabled fieldset, and the browser restoring form state, for example after a back navigation or an autofill.
  • A label for that 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.

Before and after. Before: a label with for equal to qty sits in the page, and the input with id qty sits inside the num-input shadow root, so the reference doesn't resolve and a screen reader says edit text with no name. After: the label and the input are both inside the shadow root, the reference resolves, and the screen reader says Quantity, edit text.before: id across rootslabel for=qtyshadow rootinput id=qtyscreen reader:"edit text", no nameafter: one treeshadow rootlabel for=qtyinput id=qtyscreen reader:"Quantity, edit text"
Ids live in one tree each. A 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.

ConsumerPassing dataListening for rating-changeNotes
Plain HTML and JSattributes in markup; properties via scriptel.addEventListener("rating-change", fn)Rich data needs a line of script.
React 18 and earlieralways attributes, so objects become stringsno JSX support; use a ref and addEventListener in an effectThe reason wrapper packages like @lit/react exist.
React 19a property if the element has one with that name, else an attribute; false removes the attributeonrating-change={fn} adds a listener for the name after on, case and dashes keptPasses every Custom Elements Everywhere test.
Vue 3a 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.
Sveltea property if it exists on the element, else an attributean event handler attribute or directive, depending on versionGood 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.

Host:
rating-stars is defined
React 18 rendering rating-stars, defined before the host renders. value={4}: goes by attribute, result value = 4. reviews={list}: goes by attribute, result string, data lost. compact={false}: goes by attribute, result "false" means on. onRatingChange={fn}: goes by no listener, result never runs.React 18 writesgoes byrating-stars getsvalue={4}attributevalue = 4reviews={list}attributestring, data lostcompact={false}attribute"false" means ononRatingChange={fn}no listenernever runs
React 18 breaks reviews, compact, and event. Rich data needs a property, booleans need the attribute removed, and events need a listener.

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?

No library (vanilla)

Extend HTMLElement directly

Write rendering, attribute reflection, and styles by hand

Pros

  • Zero runtime bytes and no dependency to upgrade
  • Ideal for a few tiny elements, like GitHub's small behavior elements

Cons

  • Repetitive and easy to get wrong (reflection loops, leaks, pre-upgrade properties)
  • No efficient re-rendering

Lit

A LitElement base class with reactive properties

Tagged template literals that update only the changed parts of the DOM

Pros

  • Small runtime, about 5 KB minified and compressed
  • Handles reflection, batching of updates, and shared stylesheets
  • Server rendering packages for declarative shadow DOM

Cons

  • Still a runtime dependency, which must be shared or duplicated across bundles
  • Its own templating syntax to learn

Stencil

A compiler: write TSX components with decorators

Outputs standard custom elements plus generated React, Vue, and Angular wrappers

Pros

  • Lazy-loads each component's code on first use
  • Generates framework wrappers and types for you
  • Used by Ionic's component library

Cons

  • A build tool sits between you and the output
  • Its lazy loader adds requests and its own conventions

FAST

Microsoft's base classes and design token system for web components

Foundation for the Fluent UI web components

Pros

  • Strong design token and theming story
  • Built for large design systems

Cons

  • Smaller community than Lit
  • The API has changed across major versions

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-time enhance 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.

Comparison matrix of three ways to build web components against four properties: no runtime, fast updates, wrappers, SSR output. vanilla: no runtime yes, fast updates by hand, wrappers by hand, SSR output by hand. Lit: no runtime 5 KB, fast updates yes, wrappers React, SSR output labs pkg. Stencil: no runtime small, fast updates yes, wrappers generated, SSR output yes.no runtimefast updateswrappersSSR outputvanillayesby handby handby handLit5 KByesReactlabs pkgStencilsmallyesgeneratedyes
Vanilla costs no bytes but leaves the work to you; Lit is the small middle ground; Stencil, a compiler, generates the most for you. All three output standard custom elements.

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

html
<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?

Custom element with shadow DOM

One script defines the element

The element renders into its own shadow root in the host page

Pros

  • Fits the host's layout and fonts naturally
  • Easy theming with custom properties and parts
  • Can talk to the host with events and properties, synchronously

Cons

  • Same JavaScript world: no protection from or for the host
  • Your script adds to the host's main-thread work
  • Subject to the host's Content Security Policy

Cross-origin iframe

The host embeds a page served from your origin

Communicate with postMessage

Pros

  • Real isolation of scripts, cookies, and storage
  • In browsers with site isolation it can run in its own process, so its slow code doesn't block the host's main thread
  • The host can limit it with sandbox and allow

Cons

  • Fixed box: resizing to content needs postMessage messages
  • No shared fonts or theme without passing them in
  • Third-party cookie limits affect logged-in state

Custom element that wraps an iframe

A light element on the host page handles layout, theming, and events

Sensitive parts live in an iframe inside its shadow root

Pros

  • Friendly API for the host and isolation where it matters
  • The pattern many payment providers use for card fields

Cons

  • Two pieces to build and version
  • More moving parts in the message protocol

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.

Flowchart for shipping an embeddable widget. First question: does it handle secrets or personal data? Yes leads to a cross-origin iframe, optionally wrapped in a custom element. No leads to the second question: must it match the host's theme? Yes leads to a custom element with shadow DOM. No means either works, and an iframe isolates more. A note says both choices must respect the host's script budget.your widgethandles secrets?yescross-origin iframeelement wrapper oknomust match host theme?noeither worksiframe isolates moreyescustom elementwith shadow DOMboth: watchthe script budget
Secrets decide first, because shadow DOM is not a security boundary. Only then does fitting in with the host page decide between a custom element and an iframe.

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

SymptomCauseFix
Content jumps when the page finishes loadingUndefined elements render as empty inline boxes until upgradeReserve space with my-el:not(:defined) { display: block; min-height: 48px }, or server render with declarative shadow DOM
NotSupportedError: this name has already been usedTwo bundles define the same tagLoad 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 upgradeUse React 19 or a wrapper; define elements before the host renders; re-apply properties set before upgrade
The page never hears an eventFired inside the shadow root without composed: trueDispatch on the host, or set bubbles and composed
Memory grows on route changesListeners or timers started in connectedCallback never stoppedUndo everything in disconnectedCallback; use an AbortController signal for listeners
Screen reader says "unlabelled"aria-labelledby or label for points across a shadow boundaryKeep label and control in one tree, or use ElementInternals and element reflection
Tests can't find elementsTest queries don't pierce shadow rootsUse 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.

  1. Draw the layers: host apps on top, the custom element layer in the middle, tokens as CSS custom properties underneath.
  2. State the contract: attributes for simple config, properties for rich data, events out, slots for content, parts and variables for styling.
  3. Say how it renders on the server (declarative shadow DOM or a sized placeholder) and how it joins forms (ElementInternals).
  4. Name the risks: version clashes in the global registry, accessibility across shadow roots, and that shadow DOM isn't a security boundary.
  5. 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, composed when fired inside the shadow root.
  • Clean up in disconnectedCallback whatever connectedCallback starts, and handle properties set before the element upgrades.
  • Declarative shadow DOM (shadowrootmode, Baseline 2024) server renders shadow roots; ElementInternals makes 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?

Question 2

What is the difference between attributes and properties on a custom element, and why does it matter for React?

Question 3

Does shadow DOM make a third-party widget secure?

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?

Question 5

Screen reader users say a custom combobox has no label and doesn't announce its options. What's likely wrong?

Practice it

These design problems lean on web components and shadow dom.

References

Read next