Web Components
Date: 2026-08-17
The platform’s own component model: custom elements, shadow DOM and slots. The encapsulation is genuine in a way no framework’s is — styles cannot leak in or out — and that same strictness is why they’re used far more in design systems shipped across teams than inside single applications.
Web components are reusable elements built from browser standards alone — a custom element class registered under a hyphenated tag name, optionally with a shadow DOM (Document Object Model) and slots — so they work in any framework or none.
The three pieces
They’re separable, and using one without the others is normal.
CUSTOM ELEMENTS define a new tag with lifecycle callbacks
SHADOW DOM a subtree with its own scope for styles and IDs
SLOTS holes in that subtree for the consumer's content
The smallest working form
class PriceTag extends HTMLElement {
connectedCallback() { // fires when added to the document
this.textContent = `£${this.getAttribute('amount')}`;
}
}
customElements.define('price-tag', PriceTag);<price-tag amount="24.99"></price-tag>That’s complete. No shadow DOM, no build step, no framework. The name must contain a hyphen — that’s the rule that guarantees it never collides with a future HTML element.
Adding encapsulation and a slot:
class ProductCard extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
/* scoped. cannot affect anything outside this component,
and outside CSS cannot reach in */
.card { border: 1px solid var(--card-border, #ddd); padding: 1rem; }
h3 { margin: 0 0 .5rem; font-size: 1.1rem; }
</style>
<div class="card">
<h3><slot name="title">Untitled</slot></h3>
<slot></slot> <!-- default slot -->
</div>
`;
}
}
customElements.define('product-card', ProductCard);<product-card>
<span slot="title">Navy overcoat</span>
<p>Wool blend, made in Portugal.</p>
</product-card>The lifecycle
constructor() element created. DO NOT touch attributes or
children here — they may not exist yet
connectedCallback() added to the document. ← do setup here
can fire MORE THAN ONCE if the element moves
disconnectedCallback() removed. ← clean up listeners and timers here,
or you leak — Memory and Long Sessions
attributeChangedCallback an observed attribute changed
(name, old, new) requires: static observedAttributes = ['amount']
adoptedCallback() moved to a new document. rare
connectedCallback firing more than once is the trap. Moving an element in the DOM disconnects and reconnects it, so setup that assumes it runs once will run twice.
The cleanup obligation in disconnectedCallback is not optional — an element that adds listeners and never removes them retains its whole subtree — Memory and Long Sessions.
What the encapsulation actually costs
Shadow DOM’s isolation is total, and that cuts both ways.
STYLES DON'T LEAK OUT ✓ the point
OUTSIDE STYLES DON'T REACH IN ✗ your design system's global styles
don't apply either. no utility classes,
no reset, no inherited component CSS
the sanctioned ways through:
CSS custom properties --card-border, as above ← the main one
::part(name) expose specific internals for styling
:host / :host-context style the element from inside
inherited properties font, colour still inherit
Custom properties are the styling API of a web component, and designing that surface deliberately is most of the work of making one reusable — Custom Properties, Design Tokens, Theming.
Other real costs:
- Forms don’t work by default. An
<input>inside shadow DOM isn’t associated with an outer<form>and won’t be submitted with it. Form-associated custom elements fix this and are more work than expected — Forms - Accessibility across the boundary is awkward.
aria-labelledbyreferencing an ID cannot cross into or out of a shadow root, because IDs are scoped — ARIA, The Accessibility Tree - Server rendering is limited. Declarative shadow DOM exists but framework support is uneven, so a component-heavy page may render nothing meaningful until JavaScript runs — Rendering and SEO, Progressive Enhancement
- Global search and third-party tooling — analytics selectors, session replay and testing tools may not see into shadow roots without configuration — Session Replay
Where they genuinely win
Design systems consumed by teams on different stacks. One button component that works identically in a React storefront, a Vue admin panel and a plain-HTML email preview tool is something no framework component can be — and it’s the reason large organisations reach for them.
FRAMEWORK COMPONENT WEB COMPONENT
one framework, one version any framework, or none
rich ergonomics, typed props attributes are strings; objects need
and rich composition properties set in JS
fast iteration slower, more ceremony
dies with the framework outlives it
Also good for third-party embeds — a widget dropped into someone else’s page where style collisions would otherwise be certain.
Inside one application on one framework, they’re usually the wrong choice. The framework’s own components are better integrated, better typed and less ceremony, and the isolation you’re paying for solves a problem you don’t have — Headless Components, Component API Design.
Where it interacts
- The DOM — shadow DOM is a real part of the tree with its own scoping rules
- Design Systems — the cross-stack distribution problem that makes these worth the cost
- ARIA — the boundary complicates every ID-based accessibility relationship
- Progressive Enhancement — an undefined custom element renders its children as plain HTML, which is a usable baseline if you design for it