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 communicationpostMessage 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 encapsulationwhy CSS-in-JS & CSS Modules exist.

Still worrying: nobody had all three

AttemptCompositionEncapsulationPortability
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

  1. Custom Elements — define real HTML tags with a JS lifecycle.
  2. Shadow DOM — encapsulated subtree + scoped CSS that can't leak.
  3. <template> / <slot> — inert reusable markup + composition.
  4. HTML Importsthe 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 boundaryElementInternals + formAssociated.
  • Styling deliberately locked down → must learn ::part / custom props.
  • Smaller ecosystem than React.

Old ways vs Web Components

ConcernOld wayWeb Components
Style scopingBEM / CSS-in-JSShadow DOM (enforced)
DOM isolationiframes (heavy)Shadow DOM (light)
Reuse unitframework componentnative custom element
Portabilitylocked to one frameworkworks everywhere
Runtimeship the frameworknative (0KB) or ~5KB Lit
Lifespanframework hype cyclea 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

  1. The web's original sin is global-by-default — the problem was always encapsulation, not just reuse.
  2. Every workaround solved part of it — none solved all three (composition + encapsulation + portability).
  3. Web components put all three in the platform: Custom Elements + Shadow DOM + slots.
  4. Trade-offs are real but shrinking (Lit, Declarative Shadow DOM, ElementInternals).
  5. 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. 🕊️