Chapter 45: Why Web Components, Why Lit
If the browser has a component model, we should take it seriously.
Part V is about taking it seriously. The architecture from Part IV — runtime, shell, boundaries, metadata bridge, data layer, diagnostics — is a coordination kernel that works against any DOM the application happens to produce. The chapters that follow build a component library on top of the coordination layer. Buttons, dialogs, disclosures, form controls, design tokens — the ergonomic pieces an application actually composes.
The chapters need a component primitive. The book argues that the right primitive is the platform’s own custom element, and the right authoring layer is Lit. This chapter defends both choices. The defense matters because Lit is, in the decoration-versus-replacement framing the book has been building (Chapter 33), the canonical decoration. The choice to use Lit isn’t a step away from the platform; it’s the cleanest example of decorating the platform with an opinion without hiding it in the modern frontend ecosystem.
The Web Components Story, Briefly
Section titled “The Web Components Story, Briefly”The platform’s component primitive has been a long time coming.
The first proposals for Web Components appeared at Google in 2011, led by Alex Russell (Chapter 10 mentioned him at Dojo, then Google, now Microsoft) and Dimitri Glazkov. The original vision had four pieces: Custom Elements (defining new tags), Shadow DOM (encapsulated subtrees), HTML Templates (declarative reusable fragments), and HTML Imports (loading components from external files). HTML Imports was eventually abandoned (replaced by ES modules); the other three matured into shipping standards.
Google’s Polymer library, started in 2013, was the early reference implementation — a way to author components against the still-evolving web-components specs. Polymer 1.0 shipped in 2015; Polymer 2.0 in 2017; Polymer 3.0 in 2018. Each version tracked the state of the specs and the browser implementations.
In 2019, the Polymer team announced LitElement and lit-html (the templating library it used) as the project’s successor. Polymer had been the right shape for the era when web components were experimental; Lit was the right shape for the era when they were stable. The team made a clean break: Polymer 3 would remain maintained for existing users, but new work would happen on Lit.
Lit 1.0 shipped in 2019. Lit 2.0 in 2021 unified the previously-separate LitElement and lit-html packages. Lit 3.0 in 2023 brought TypeScript modernization, smaller bundles, and a polished API. As of 2025, Lit 3.x is the version most teams use, and the library has stabilized into a mature, focused, well-engineered component authoring layer.
The People
Section titled “The People”The Lit team has been remarkably stable, which is part of why the library has been able to take the long view.
Justin Fagnani has been a core engineer on the project since the Polymer days. His work on lit-html — the templating engine that Lit builds on — produced one of the most elegant templating systems in the JavaScript ecosystem. The tagged-template-literal approach (html\<button @click=${handler}>${label}“) was a design choice that turned out to be far better than the JSX-style transformation alternative the team also explored.
Steve Orvell led the Polymer-to-Lit transition and continues to lead Lit’s development at Google. His architectural decisions — keep the library small, prefer the platform’s primitives, refuse to grow scope — are why Lit has avoided the framework-replacement trajectory most successful UI libraries have taken.
Kevin Schaaf has been a long-running contributor and the steward of the project’s documentation, examples, and developer-relations work. The Lit website’s quality (it’s one of the best technical documentation sites in the frontend ecosystem) is in significant part Schaaf’s work.
The broader team includes Augustine Kim, Elliott Marquez, Lance Larsen, and a steady set of contributors across Google’s Chrome team, partner companies, and the open-source community. The project is governed under Google’s stewardship but its API design has consistently prioritized platform alignment over Google-specific concerns.
The reverence beat the chapter has to land: the Lit team has been doing patient, principled, mostly-thankless work for over a decade. The library is a meaningful contribution to the frontend ecosystem precisely because it stayed small and stayed honest about what it was trying to be. The chapter’s argument depends on that work; the rest of Part V is built on the foundation the Lit team has provided.
Why Custom Elements Are the Right Primitive
Section titled “Why Custom Elements Are the Right Primitive”Before defending Lit specifically, the chapter has to defend the underlying choice: custom elements as the component model.
A custom element is a real platform element. The browser knows about it. The DOM contains it. The accessibility tree includes it. CSS targets it. The query selector finds it. The event model fires events on it. The form-control machinery (when it’s form-associated, Chapter 24) treats it as a participant. It survives framework changes — a custom element defined today still works if the team’s preferred framework changes next year, because the element belongs to the platform.
Compare this to a component in a framework’s parallel reality. A React component exists in the virtual DOM. Outside React, nothing knows it exists. The element rendered by React is a plain <div> or <button> that the framework happens to have produced; the component itself isn’t a thing the platform can see. Replace React with Vue or Svelte, and the component has to be rewritten.
The decoration-versus-replacement framing from Chapter 33 applies directly. A custom element is decoration — it adds a new HTML element to the platform’s vocabulary, but the element is a real platform element that participates in everything the platform already does. A framework component is replacement — it substitutes a parallel reality for the platform’s element model.
For the architecture this book builds, the custom-element choice has specific consequences:
Components compose with everything. A <kit-button> works inside server-rendered HTML, inside a React application, inside an Astro page with framework islands, inside an AI-generated markup blob. The custom element doesn’t care which renderer produced its surrounding context. It’s a platform element; the surrounding context is the DOM.
Components survive framework changes. The application’s framework can change without the component library having to change. A team that built on Lit in 2020 still has working components in 2026; a team that built on React class components in 2017 has been migrating to hooks ever since.
Components participate in the metadata protocol. A <kit-button meta-event="profile.saved"> fits naturally into the architecture from Part IV. The metadata boundary listener observes the click, walks up to collect context, dispatches the runtime event. The component doesn’t need framework-specific integration; the DOM is the integration.
Components are testable as elements. A test can render <kit-button> into a DOM fragment, dispatch events on it, query its accessibility properties, inspect its attributes — using standard DOM APIs, without a framework-specific test renderer.
Components are debuggable from the browser. Browser dev tools show the element. The Elements panel inspects its attributes. The Console panel can call methods on it. The Accessibility panel shows how it appears to assistive technology. No framework-specific dev tools are required.
These properties compound. A component library built on custom elements is more portable, more durable, more inspectable, and more composable than one built on a framework’s component model. The cost is the authoring ergonomics — writing custom elements directly is verbose, with manual lifecycle, manual property reactivity, and manual templating. That’s the gap Lit fills.
Why Lit Is the Right Decoration
Section titled “Why Lit Is the Right Decoration”Lit is a thin layer that makes custom elements pleasant to author without hiding any of the platform underneath.
A Lit component looks like this:
import { LitElement, html, css } from 'lit'import { property } from 'lit/decorators.js'
class KitButton extends LitElement { @property({ type: String }) label = '' @property({ type: Boolean, reflect: true }) disabled = false
static styles = css` button { padding: 0.5em 1em; border-radius: 4px; border: 1px solid currentColor; background: transparent; color: inherit; cursor: pointer; } button[disabled] { cursor: not-allowed; opacity: 0.5; } `
render() { return html` <button ?disabled=${this.disabled} @click=${this.handleClick}> <slot>${this.label}</slot> </button> ` }
private handleClick(event: Event) { this.dispatchEvent(new CustomEvent('kit-click', { bubbles: true, composed: true, detail: { originalEvent: event } })) }}
customElements.define('kit-button', KitButton)The result is a real custom element. The browser registers it under the tag name kit-button. The application can use it in HTML, in Lit templates, in React (<kit-button>), in Vue, in server-rendered output, anywhere. The element’s label and disabled properties are reactive — assigning to them re-renders. The CSS is scoped to the element’s shadow DOM. The click handler dispatches a composed kit-click event that bubbles up the regular DOM.
What Lit added on top of plain custom elements:
Reactive properties. The @property decorator declares properties that, when changed, trigger a re-render. Behind the scenes, Lit uses the platform’s observedAttributes and attributeChangedCallback for attributes, and its own lightweight reactivity system for property assignments.
Tagged-template templating. The html\…“ template tag is a function call that returns a description of the DOM to render. Lit’s runtime compares the new description to the previous one and updates only the parts that changed. The mechanism is similar to React’s virtual DOM but operates on a much smaller representation (just the parts that change) and produces real DOM updates directly.
Scoped styles. The css\…`template tag is similar, for the component's stylesheet. Lit uses Shadow DOM by default to scope the styles; styles inside don't leak out, and styles outside don't leak in (except throughinherit`-able properties and custom properties — the cascade that the application wants).
Slots. The component can declare slots in its template (the unnamed <slot> in the example above, plus named slots like <slot name="icon">). The application places content into the slots; the component renders around it. The slot pattern is the platform’s native way to compose content with components, and Lit makes it ergonomic.
Lifecycle. Lit exposes the platform’s custom-element lifecycle (connectedCallback, disconnectedCallback, attributeChangedCallback) and adds its own (updated, firstUpdated, willUpdate) for reactive scenarios. The lifecycle is a thin layer over the platform’s; debugging is straightforward.
TypeScript integration. The Lit team has invested in good TypeScript types. Decorators, template literals, event types — all of them work cleanly in TypeScript. The development experience is similar to React’s, with the same kind of editor support and autocomplete.
Lit’s total runtime size is around 8 KB minified and gzipped. The library does little; the platform does the rest.
Decoration in Practice
Section titled “Decoration in Practice”The decoration-versus-replacement claim deserves a concrete demonstration.
A <kit-button> rendered into the page produces this in the actual DOM:
<kit-button label="Save" disabled> #shadow-root (open) <button disabled> <slot></slot> </button></kit-button>Application code outside the component can do all of the following:
Query it: document.querySelector('kit-button') finds it.
Read its attributes: element.getAttribute('disabled'), element.label, element.disabled.
Listen to its events: element.addEventListener('kit-click', handler).
Manipulate it: element.label = 'Updated', element.disabled = false — the property assignments trigger Lit’s reactivity, the DOM updates, the rest of the application observes the change normally.
Style around it: kit-button { margin: 1em; } works from any stylesheet. kit-button::part(button) (if Lit declared the part) could target internals through the platform’s part mechanism.
Test it: A test can mount <kit-button> in a DOM, simulate events, query the resulting state, and verify behavior — using standard DOM APIs.
Use it from any framework: <kit-button> inside a React component, a Vue template, a server-rendered Rails view, an Astro page, or a vanilla-HTML file all behave identically. The component is a platform element; the framework is just the producer of the surrounding context.
This is what decoration means in practice. Lit added an opinion to the platform’s custom-element primitive. The opinion is small (an authoring API and a reactivity system). The platform is preserved (the component is still a real custom element). The application gets the ergonomics; the platform keeps the contracts.
If Lit disappeared tomorrow — the team stopped maintaining it, the project was archived, the npm package was removed — the components built on Lit would still work, because they’re real custom elements. The application would need to maintain its Lit runtime (which is open source and small enough to vendor), but the DOM behavior would be unchanged. Compare this to a React application after a hypothetical React shutdown: every component would need to be rewritten against a different framework.
Lit Doesn’t Own the Application
Section titled “Lit Doesn’t Own the Application”A specific property worth landing.
Lit is a component authoring layer. It isn’t a state-management library. It isn’t a router. It isn’t a build tool. It isn’t a meta-framework. It isn’t an application architecture. It builds custom elements.
This is intentional. The Lit team has consistently declined to grow the project into a full framework. Routing is the application’s responsibility (or the meta-framework’s, if one is in use). State management is the application’s responsibility. Application architecture — the coordination kernel built in Part IV — is the application’s responsibility. Lit just helps you write components.
For Kitsune’s architecture, this is exactly the right shape. The coordination layer from Part IV doesn’t need Lit. Lit doesn’t need the coordination layer. The two compose cleanly because each one has a narrow scope. The component library that Part V builds uses Lit to author custom elements; the elements participate in the architecture through the metadata protocol; the runtime routes events the elements dispatch; the modules respond.
A team using the Kit component library doesn’t have to use the Kitsune runtime. The components are just custom elements. They work without the runtime. They work better with the runtime, because the runtime’s metadata boundary observes their events and routes them through the architecture. The components and the runtime are independently useful.
This is also the property that makes the architecture composable with other tools. A team using React can use Kit components inside their React tree. A team using Astro can render Kit components alongside React or Vue components on the same page. A team using a server-side framework (Rails, Django, Astro) can drop Kit components into their server-rendered HTML. The components don’t care; they’re platform elements.
Real-World Lit Adoption
Section titled “Real-World Lit Adoption”A short tour of where Lit ships in production.
Adobe uses Lit for its Spectrum Web Components design system, which underlies the company’s web-based applications.
ING Bank (Netherlands) maintains Lion, a Lit-based component library used across the bank’s web applications.
SAP uses Lit (alongside other technologies) in parts of its UI5 web components library.
Photoshop on the Web and Illustrator on the Web use Lit for some of their UI layers.
MIT uses Lit for its design system across several university web properties.
The U.S. government’s design system (USWDS variants) has Lit-based implementations in several agency contexts.
The list isn’t enormous, but the deployments are substantial. Lit isn’t the dominant component library by adoption count — React still is — but it’s the dominant web-components authoring library, and its deployments tend to be in contexts where the durability and platform alignment matter (banking, government, enterprise design systems, long-running consumer applications).
For Kitsune, which is aimed at applications that want the platform’s evergreen guarantees and long-term-maintenance posture, Lit is the right fit. The library is mature, the team is committed, the design is principled. Building on Lit is a vote for the same architectural values the rest of the book has been arguing for.
What Comes Next
Section titled “What Comes Next”The next chapters build the Kit component library piece by piece. Chapter 46 builds kit-button — the simplest case, with native <button> underneath, semantic metadata exposed, accessible behavior preserved. Chapter 47 builds kit-dialog using native <dialog>. Chapter 48 handles disclosure, select, and native controls. Chapter 49 covers forms and form-associated custom elements. Chapter 50 covers styling with tokens, cascade layers, and container queries. Chapter 51 ties everything together with the design-systems story (Brad Frost, atomic design, Style Dictionary, Shadcn-style component-copying).
Each component the rest of Part V builds is a real custom element. Each one uses Lit for authoring. Each one preserves the platform’s contracts. The architectural shape from Part IV — components describing meaning, boundaries supplying context, modules handling consequences — extends naturally into the component library.
Exercise: Build a Web-Native Counter
Section titled “Exercise: Build a Web-Native Counter”Build a small custom element using Lit:
import { LitElement, html, css } from 'lit'import { property, state } from 'lit/decorators.js'
class DemoCounter extends LitElement { @property({ type: Number }) initial = 0 @state() private count = this.initial
static styles = css` button { padding: 0.5em 1em; font-size: inherit; } `
render() { return html` <button @click=${this.increment}> Count: ${this.count} </button> ` }
private increment() { this.count++ this.dispatchEvent(new CustomEvent('count-changed', { bubbles: true, composed: true, detail: { count: this.count } })) }}
customElements.define('demo-counter', DemoCounter)Then experiment:
- Use
<demo-counter initial="5">in a plain HTML file. Verify the initial value applies. - Use the same element inside a React component (with the right type declarations). Verify it works identically.
- Listen for the
count-changedevent from outside the element. Verify the event fires with the right detail. - Programmatically set
element.initial = 10and verify the property updates (though note: the initial value only applies on the first render; you’ll need to handle this with a different pattern if you want it to reset). - Inspect the element in browser dev tools. Look at the Shadow DOM. Look at the accessibility tree.
- Add a
disabledproperty using@property({ type: Boolean, reflect: true })and styling that responds to[disabled].
Then think about how the element fits into the Part IV architecture:
- If the application had a runtime, where would
count-changedgo? (Either throughmeta:eventdispatching or through aruntime.emitcall from outside the component.) - How could the component participate in the metadata protocol? (Add a
meta-eventattribute that the metadata-boundary listener observes.) - How would you make the counter form-associated, so its value participates in
FormData? (Chapter 49 builds this.)
The exercise should feel light. Lit’s authoring ergonomics are deliberately close to what working frontend developers are used to. The change is in what the element is (a real platform element, not a framework artifact) rather than in how it’s written.