How I Learned to Stop Worrying and
Love Web Components
The original problem · a decade of workarounds · the standard that absorbed their lessons
ACT I
The Worrying
Why the web made us anxious
Built for documents, not apps
- HTML/CSS/JS were designed in 1991–1995 to show linked documents.
- We then asked them to run Gmail, Figma, Spotify.
- The mismatch shows up as one recurring word: global.
The original problem: everything is global
/* widget-a.css */
.button { background: blue; }
/* widget-b.css — another team, another year */
.button { background: red; } /* 💥 now BOTH are red */
- CSS cascades globally — one rule restyles every
.button on the page.
- The DOM is one shared tree — any script can reach into any component's guts.
- IDs + JS namespace are global — collisions everywhere.
- No native "component" primitive — can't ship a self-contained
<date-picker>.
What "solved" looks like
A real component primitive needs three things at once:
- 1. Composition — build big things from small, pass content in.
- 2. Encapsulation — styles & DOM that don't leak in or out.
- 3. Portability — works anywhere, no framework lock-in.
Spoiler: every early attempt nailed one or two — never all three.
ACT II
A Decade of Coping
Workarounds we told ourselves were fine
Attempt #1 — Server includes & templating
PHP include, Rails partials, ERB/Handlebars.
- ✅ Composition / DRY — write the nav once, reuse everywhere.
- ❌ No client-side encapsulation — flat HTML in the global soup.
- ❌ Render-time only — no interactivity boundary, no live component.
Attempt #2 — jQuery plugins & jQuery UI
$("#datepicker").datepicker(); // drop-in behavior
- ✅ Packaged behavior; enormous ecosystem; "just drop it in."
- ❌ Global CSS leakage — plugin styles fight your styles.
- ❌ No DOM encapsulation — internals exposed, easily broken.
- ❌ Imperative & stateful-by-hand — fragile at scale.
Attempt #3 — CSS methodologies (BEM)
.card {}
.card__title {}
.card__title--featured {}
- ✅ Discipline-based scoping via strict naming conventions.
- ❌ Convention, not enforcement — one typo and it leaks.
- ❌ Verbose; cognitive overhead; doesn't scale across teams.
BEM is humans manually simulating the encapsulation the platform never gave them.
Attempt #4 — iframes (the nuclear option)
- ✅ TRUE isolation — separate document, CSS, DOM. Real encapsulation.
- ❌ Heavy — a whole document per widget.
- ❌ Awkward communication —
postMessage only.
- ❌ Terrible for layout, accessibility, SEO, shared state.
Attempt #5 — JS frameworks (Angular → React/Vue)
function Card({ title }) {
return <div className="card">{title}</div>;
}
- ✅ Real componentization — composition, reactivity, lifecycle. A huge leap.
- ❌ Framework lock-in — a React
<Card> can't run in Vue/Angular.
- ❌ Runtime cost — you must ship the framework.
- ❌ Still no native style encapsulation — why CSS-in-JS & CSS Modules exist.
Still worrying: nobody had all three
| Attempt | Composition | Encapsulation | Portability |
| Server includes | ✅ | ❌ | ❌ |
| jQuery plugins | ~ | ❌ | ~ |
| BEM / CSS conventions | ❌ | ~ faked | ❌ |
| iframes | ❌ | ✅ real! | ✅ |
| JS frameworks | ✅ | ~ bolted-on | ❌ |
Nobody had all three at once. That gap is the opening.
ACT III
Learning to Love
Stop fighting the platform — embrace it
How web components came about
- 2011 — Alex Russell (Google) coins "Web Components." Goal: push componentization into the browser.
- Polymer — Google's library that proved the model & stress-tested the specs.
- A rough "v0" of the specs → re-designed → "v1" standardized.
- Baseline in every modern browser (Chrome, Firefox, Safari, Edge) since ~2018–2020.
The four building blocks
- Custom Elements — define real HTML tags with a JS lifecycle.
- Shadow DOM — encapsulated subtree + scoped CSS that can't leak.
- <template> / <slot> — inert reusable markup + composition.
- HTML Imports — the one that died — replaced by ES modules.
Custom Elements
class UserCard extends HTMLElement {
static observedAttributes = ["name"];
connectedCallback() { // inserted into the DOM
this.render();
}
attributeChangedCallback(attr, oldV, newV) {
this.render(); // reactivity
}
render() {
this.textContent = `👤 ${this.getAttribute("name")}`;
}
}
customElements.define("user-card", UserCard);
<user-card name="Ada"></user-card> <!-- a real, native element -->
Shadow DOM — the killer feature
class FancyBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
/* styles ONLY inside this component */
.title { color: rebeccapurple; font: bold 1.5rem system-ui; }
</style>
<div class="title"><slot></slot></div>
`;
}
}
customElements.define("fancy-box", FancyBox);
The page's global .title { color: red } cannot reach inside. Browser-enforced.
Composition with slots
<fancy-box>
Hello <strong>world</strong> <!-- "light DOM" projected into <slot> -->
</fancy-box>
<!-- named slots -->
<my-dialog>
<h2 slot="header">Confirm</h2>
<p>Are you sure?</p>
<button slot="footer">OK</button>
</my-dialog>
Styling across the boundary on purpose
The boundary blocks accidental leakage — so you get deliberate hooks:
/* 1. CSS custom properties pierce the boundary (by design) */
fancy-box { --box-accent: teal; }
/* 2. ::part() — expose specific internals */
fancy-box::part(title) { letter-spacing: .04em; }
/* 3. ::slotted() — style projected light-DOM nodes */
::slotted(strong) { color: crimson; }
Why a library? Vanilla vs Lit
// Vanilla: verbose, manual DOM, no reactivity
render() { this.shadowRoot.innerHTML = `<p>${this.count}</p>`; } // re-stringify all
// Lit (~5KB): declarative templates + reactive properties
import { LitElement, html } from "lit";
class Counter extends LitElement {
static properties = { count: { type: Number } };
render() { return html`<p>${this.count}</p>`; } // efficient, auto re-renders
}
customElements.define("my-counter", Counter);
Loving with eyes open honest drawbacks
- Verbose vanilla API → mitigated by Lit/Stencil.
- No built-in state management like React/Vue.
- SSR was hard → now Declarative Shadow DOM (
<template shadowrootmode>).
- A11y & forms across the boundary → ElementInternals +
formAssociated.
- Styling deliberately locked down → must learn
::part / custom props.
- Smaller ecosystem than React.
Old ways vs Web Components
| Concern | Old way | Web Components |
| Style scoping | BEM / CSS-in-JS | Shadow DOM (enforced) |
| DOM isolation | iframes (heavy) | Shadow DOM (light) |
| Reuse unit | framework component | native custom element |
| Portability | locked to one framework | works everywhere |
| Runtime | ship the framework | native (0KB) or ~5KB Lit |
| Lifespan | framework hype cycle | a web standard |
Not here to kill React
The interoperable foundation underneath frameworks — not a replacement.
Design systems that must work everywhere ship them:
- Adobe Spectrum · Shoelace / Web Awesome
- Microsoft FAST / Fluent · GitHub (
<details-menu>)
- Salesforce Lightning · SAP UI5 · ServiceNow
Live demo: watch CSS leak, then get contained
plain-widget · no shadow DOM
shadow-widget · Shadow DOM
These are real custom elements running in this slide. 🤯
Takeaways
- The web's original sin is global-by-default — the problem was always encapsulation, not just reuse.
- Every workaround solved part of it — none solved all three (composition + encapsulation + portability).
- Web components put all three in the platform: Custom Elements + Shadow DOM + slots.
- Trade-offs are real but shrinking (Lit, Declarative Shadow DOM, ElementInternals).
- Best fit: design systems & cross-framework, long-lived UI.
Frameworks come and go; the platform is forever. Web components are a bet on the platform.
…and that's how I stopped worrying. 🕊️