Web Components are the browser's native answer to the component question: custom elements with real tag names, shadow DOM scoping, slots for content projection and templates for inert markup. The pitch is durability, a component written against platform standards outliving the framework that would otherwise wrap it, and the pitch is partly true and partly a trap. The standards are real, the support is genuinely universal in modern browsers, and the seams where they meet the frameworks teams actually use are the part that decides whether production adoption is a foundation or a museum piece. This article walks the platform pieces, the browser reality, the framework interop details that cause the actual production defects, and the architecture where the technology earns its keep without asking a team to bet the product on it.
The four standards and what each contributes
Custom elements are the entry point: a class extending HTMLElement, registered with a tag name carrying a dash:
class StockBadge extends HTMLElement {
connectedCallback() {
this.innerHTML = `<span class="badge">${this.dataset.symbol}</span>`
}
}
customElements.define('stock-badge', StockBadge)
The browser treats the tag as a first class element, the lifecycle callbacks, connected, disconnected, attribute changed, run like any other element's events, and the element works in any markup, any framework, any template language, because to the parser it is just a tag. Shadow DOM attaches an encapsulated subtree to the element, where styles in and out do not cross the boundary, and the component owns its appearance without a class naming convention or a build step. Slots mark projection points inside the shadow tree, so the consumer composes content through the component:
<product-card>
<span slot="title">Steel Knife</span>
<span slot="price">39</span>
</product-card>
Templates hold inert markup, parsed once, cloned cheaply, the platform's stamp for repeated structures. The four together cover what a component model needs: naming, encapsulation, composition and reuse, without a runtime the page must download.
The browser reality and the server side gap
The client side support story is settled: custom elements, shadow DOM and slots work in every current browser, and the old polyfills era is over. The gap that remains is server side rendering, and it is the first production seam. The shadow DOM does not serialize, the browser builds it at runtime, so a server rendered page arrives with the custom tags unpainted, and the layout shifts when the elements upgrade, which the Core Web Vitals work measures as a real user cost on every load.
The declarative shadow DOM closes the gap at the platform level, a template element inside the custom tag that the parser itself instantiates as the shadow tree, so the server can ship the shadow DOM's markup inside the HTML and the page paints without JavaScript:
<stock-badge>
<template shadowrootmode="open">
<span class="badge"><slot></slot></span>
</template>
STEEL-KNIFE
</stock-badge>
The attribute has settled on shadowrootmode across browsers after an earlier naming iteration, and the support is current in the evergreen set. The discipline it demands is that the server and the client must agree on the shadow markup, which pushes teams toward a build step that renders the template from one source, and the honest production assessment is that the tooling around this pipeline is younger than the frameworks it supplements, which is the recurring theme of the seams below.
Framework interop and the two seams that cause defects
A custom element in a framework page is where the defects live, because the frameworks have opinions about elements and the platform element does not share them. The first seam is attributes versus properties. The platform convention is attributes for strings, properties for objects, and a framework that sets an attribute where the component reads a property writes data the component never sees. React historically passed only strings and numbers as attributes and required explicit handling for anything richer, and its newer versions improved the forwarding of properties to custom elements, but the team adopting a component library must still test the exact pair, component and framework version, because the seam has moved between releases.
The second seam is events. The platform pattern for rich custom events is the CustomEvent constructor, dispatched with detail payload, and the framework's synthetic event system does not see it unless the listener is attached the platform way. React needed a manual ref and addEventListener for years, and the newer React builds listen for standard and custom events on web components directly, while Vue and Solid handle the platform events natively, and the production rule is identical for every pair: the integration test renders the component in the actual framework and asserts the event arrives, because the documentation of both sides describes the intent and the test observes the truth.
The third seam is forms, worth naming because it decides whether component libraries can participate in real applications. The ElementInternals interface lets a custom element register as a form participant, expose a value and a validity, and receive the same validation flow native inputs get:
class ZipInput extends HTMLElement {
static formAssociated = true
connectedCallback() {
this.internals = this.attachInternals()
this.internals.setValidity(
{ patternMismatch: this.value.length !== 5 }, 'Enter 5 digits')
}
}
The support is current in the evergreen browsers, and a component library without it forces its consumers to wire every field's state manually, which is the difference between a component set that applications can adopt and a demo gallery.
Where the technology genuinely earns its keep
The durable adoption cases share one property: content that must outlive application frameworks. Design systems are the first: a company shipping a button, a badge, an input and a card that every product consumes, where the products run three frameworks and the design system refuses to pick one, and the platform component is the only packaging that needs no adapter per consumer. Embedded widgets are the second: the video player, the payment form, the chat launcher that third parties paste into their pages, where the iframe alternative costs a boundary and the custom element costs a script tag, and the widget's lifetime spans consumer generations by design. Long lived internal libraries are the third: the date picker that has survived two framework migrations already and will survive the third, extracted once into the platform layer and left there.
The cases where the technology does not earn its keep are equally specific. An application's own pages, rendered by one framework with one team, gain nothing from the platform layer and lose the framework's ergonomics, reactivity, devtools and the component ecosystem, for a purity nobody invoiced. The rewrite instinct arrives here in its usual costume, a proposal to migrate working framework components to the platform for durability, and it fails for the usual reason, the durability nobody needs is purchased with the ergonomics the team uses hourly.
Lit and the thin layer that makes the platform writable
Writing raw custom elements is verbose, and the ecosystem's answer is Lit, a library that is deliberately thin, a few kilobytes, that turns the platform APIs into a declarative base class:
import { LitElement, html, css } from 'lit'
class StockBadge extends LitElement {
static properties = { symbol: { type: String } }
static styles = css`
.badge { padding: 2px 8px; border-radius: 4px;
background: var(--badge-bg) }
`
render() {
return html`<span class="badge">${this.symbol}</span>`
}
}
customElements.define('stock-badge', StockBadge)
The reactive properties, the scoped styles and the template in one class remove the ceremony without hiding the platform underneath. The element is still an HTMLElement, the shadow DOM is still the encapsulation, and a team that later drops the library keeps the elements it shipped, which is the property that separates a thin layer from a framework. Lit is the de facto standard in this space for good reasons, and the production questions about it are the ordinary library questions, version policy, browser targets and test tooling, rather than framework migration questions.
The styling contract through custom properties
The shadow boundary cuts both ways, and the consumer needs a styling contract or the component is a black box. The platform provides the right one, CSS custom properties cross the shadow boundary, so the component declares its themable values as variables inside :host and the consumer theme is a plain stylesheet:
:host {
--badge-bg: #2563eb;
--badge-fg: white;
}
The design system documents the variable list per component, the theme is one file of property overrides, and page styles cannot leak into the component by accident. The discipline that makes this a contract rather than a convention is the same as any API, the variables are named, versioned and changed through deprecation, because a renamed property breaks every consumer theme at once.
The architecture that uses both without friction
The production architecture that works is a layering, and it matches the federation and rendering decisions from the earlier articles. The application keeps its framework, its routing, its state and its rendering decisions. The design system outputs platform components where the consumers are heterogeneous, and framework wrappers where a consumer wants idiomatic props, a thin adapter generated from the platform source so the two never drift. The widgets the product ships to third parties are platform components with the declarative shadow DOM, so they paint server side and upgrade progressively. The result is a portfolio rather than a bet: the framework carries the product, the platform carries the shared surface, and each layer's choice is justified by its consumers instead of by an ideology about the other layer.
The adoption checklist
The proposal to add platform components to a product survives review with the following answered in advance:
- Name the consumers, and count the frameworks among them, the count is the case;
- Test each component with each consuming framework version, the seams move between releases;
- Ship the shadow tree declaratively where server rendering matters, and measure the layout shift;
- Register form components through ElementInternals or do not ship form components at all;
- Generate framework wrappers from the platform source, one source of truth, adapters only;
- Reserve the platform layer for shared surfaces, and keep application pages in the framework.
The standards are stable, the support is universal, and the seams are the entire engineering cost. The team that scopes the platform layer to the components whose consumers demand portability builds something durable on the platform without paying the ergonomics tax anywhere it hurts, which is the only version of this adoption that has ever finished on schedule.
Accessibility as a platform obligation
A custom element is a real element, and that makes accessibility a first class obligation rather than a framework concern delegated downstream. The platform gives the component author the same primitives the browser's own controls use: ARIA roles on the host, internal focus management through tabindex and the focusin and focusout events, and keyboard handling in the component's own listeners. A component that behaves like a listbox implements the arrow key traversal, the roving tabindex and the aria-activedescendant pattern, because assistive technology reads the element with the same rules it reads anything else on the page, and a shadow tree that hides semantic markup from the accessibility tree unless the roles are explicit is a trap the naive component falls into on its first audit. The screen reader session from the accessibility work in this series applies to platform components with one addition, the shadow boundary makes manual inspection mandatory, since the automated scanners see less inside it, and the component library that ships without a keyboard and reader pass is a library that will be rewritten around.
The devtools and the debugging reality
The debugging story for platform components is genuinely good in the current browser tools: the Elements panel renders the shadow tree in place, the custom element's properties and methods are inspectable on the node, and the shadow boundary is a expandable region instead of an invisible wall. The gap versus framework devtools is the state story, there is no component tree view with reactive dependency graphs, because there is no reactivity graph, the state is the element's properties and the debugging is the ordinary element inspection. Teams migrating from framework tooling report the same learning curve both directions, and the honest summary is that the platform layer trades the framework's state introspection for the universal, framework free inspection that works on every page ever shipped.