Web Development · Frontend
Lit and Web Components in 2026: The Framework-Agnostic Layer Worth Knowing
If your design system needs to work in React, Vue, and a legacy jQuery app simultaneously, Web Components are the only native answer. Lit makes them practical to write.
Anurag Verma
9 min read
Sponsored
Adobe Spectrum, GitHub Primer, and YouTube’s core UI all ship as Web Components. Not because the teams involved wanted to be clever, but because they faced a real constraint: their components need to work in contexts they don’t control. A design system that only runs in React is a design system that half your users can’t use.
Web Components are a set of browser-native APIs — Custom Elements, Shadow DOM, HTML Templates — that let you define reusable elements that work in any HTML context, with any framework or none. Lit is Google’s library for writing them without the boilerplate.
If your work is in a single-framework monorepo, you may never need this. If you’re building a component library for others, or maintaining a product that spans multiple frontend stacks, understanding Web Components is worth the time.
What Web Components Actually Are
Three APIs combine to make Web Components:
Custom Elements let you define new HTML tags. <my-card>, <app-navigation>, <data-table> — anything with a hyphen in the name. The browser registers these with customElements.define() and treats them like any built-in element.
Shadow DOM is an encapsulated DOM subtree attached to a Custom Element. Styles inside the shadow root don’t leak out, and external styles don’t pierce in (by default). This is CSS encapsulation at the browser level, not a build tool convention.
HTML Templates (<template> element) hold markup that isn’t rendered until it’s cloned and inserted. Custom Elements use these as their internal structure.
Together, these give you a component system that is part of the browser standard. No framework required.
Why Not Bare Web Components?
Writing a Web Component directly with the browser APIs is verbose. Here is a minimal custom button in bare Web Components:
class MyButton extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
static get observedAttributes() { return ['label', 'disabled'] }
attributeChangedCallback(name, oldVal, newVal) {
this.render()
}
connectedCallback() {
this.render()
}
render() {
const label = this.getAttribute('label') || 'Button'
const disabled = this.hasAttribute('disabled')
this.shadowRoot.innerHTML = `
<style>
button {
padding: 8px 16px;
border-radius: 4px;
cursor: ${disabled ? 'not-allowed' : 'pointer'};
}
</style>
<button ${disabled ? 'disabled' : ''}>${label}</button>
`
}
}
customElements.define('my-button', MyButton)
This works, but it manually manages re-renders, doesn’t have reactive properties, and uses string concatenation for templates. For anything more complex, you’re writing a lot of boilerplate.
Lit: the Minimal Abstraction
Lit (by Google, CNCF project) is roughly 5KB minified and gzipped. It adds three things to bare Web Components:
- Reactive properties: declared with
@property()or@state()decorators, changes trigger efficient DOM updates - Lit HTML templates: tagged template literals (
html\…“) for declarative rendering, only updating what changed - Lit CSS: tagged template literals (
css\…“) for scoped styles
The same button in Lit:
import { LitElement, html, css } from 'lit'
import { customElement, property } from 'lit/decorators.js'
@customElement('my-button')
export class MyButton extends LitElement {
@property({ type: String }) label = 'Button'
@property({ type: Boolean }) disabled = false
static styles = css`
button {
padding: 8px 16px;
border-radius: 4px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
`
render() {
return html`
<button ?disabled=${this.disabled}>
${this.label}
</button>
`
}
}
Declared @property() values are observed attributes automatically. Changing myButton.label = "Submit" triggers a re-render of only the parts that changed. The ?disabled binding handles boolean attributes correctly.
Lit’s template system does not use a virtual DOM. It walks the template once and marks the binding locations, then on updates it only touches those locations. This is faster than virtual DOM diffing for components that render frequently.
Shadow DOM and CSS Encapsulation
The Shadow DOM is what makes Web Components composable with any framework. Styles inside the shadow root are scoped to that component automatically.
@customElement('my-card')
export class MyCard extends LitElement {
static styles = css`
/* These styles are scoped to this component */
:host {
display: block;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.header {
padding: 16px 24px;
border-bottom: 1px solid #eee;
font-weight: 600;
}
.body {
padding: 24px;
}
`
@property() heading = ''
render() {
return html`
<div class="header">${this.heading}</div>
<div class="body">
<slot></slot>
</div>
`
}
}
Drop this component into any page — React app, Vue app, plain HTML — and the .header and .body styles will never conflict with the host page’s CSS. A Bootstrap card class on the host page has no effect on the internal layout.
<slot> is how Shadow DOM handles children. Content placed inside the custom element gets projected into the slot:
<my-card heading="User Profile">
<p>This content is slotted into the .body div</p>
</my-card>
Named slots allow multiple content areas:
render() {
return html`
<div class="header">
<slot name="header"></slot>
</div>
<div class="body">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
`
}
<my-card>
<h2 slot="header">Profile</h2>
<p>Main content</p>
<button slot="footer">Save</button>
</my-card>
CSS Custom Properties: The Theming API
Since external styles don’t pierce Shadow DOM, how do you let consumers theme components? CSS custom properties (variables) do cross the shadow boundary. This is by design.
static styles = css`
:host {
--button-bg: #0066cc;
--button-color: #fff;
--button-radius: 4px;
}
button {
background: var(--button-bg);
color: var(--button-color);
border-radius: var(--button-radius);
padding: 8px 16px;
border: none;
cursor: pointer;
}
`
The consumer can override:
/* In any stylesheet that reaches the host element */
my-button {
--button-bg: #d32f2f;
--button-radius: 20px;
}
This gives you a well-defined theming API. Consumers can change what you expose through variables. They cannot change anything else. This is cleaner than the “add a class and hope the specificity works” approach to theming.
Using Web Components in React and Vue
React 19 improved Web Components interoperability significantly. In React 19+:
// React 19: properties and events work correctly
function ProfilePage() {
const handleAction = (e: CustomEvent) => {
console.log(e.detail)
}
return (
<my-card
heading="User Profile"
onmy-action={handleAction}
>
<p>Content</p>
</my-card>
)
}
Before React 19, passing non-string properties to custom elements required refs and manual element.property = value assignments. React 19 handles property binding for custom elements natively. If you’re on React 18 and need to use Web Components, the @lit/react package provides a wrapper:
import { createComponent } from '@lit/react'
import React from 'react'
import { MyCard } from './my-card'
export const MyCardReact = createComponent({
tagName: 'my-card',
elementClass: MyCard,
react: React,
events: {
onMyAction: 'my-action',
},
})
// Now use it with full type safety
function ProfilePage() {
return <MyCardReact heading="Profile" onMyAction={(e) => console.log(e.detail)} />
}
In Vue, custom elements work out of the box with no extra configuration:
<template>
<my-card :heading="title" @my-action="handleAction">
<p>Content</p>
</my-card>
</template>
<script setup>
const title = ref('User Profile')
const handleAction = (e) => console.log(e.detail)
</script>
Vue’s template compiler recognizes elements with hyphens as custom elements and passes properties and events correctly.
When to Use Lit vs. Framework Components
Lit is appropriate when:
- You’re building a design system or component library that needs to be consumed by teams using different frameworks
- You’re migrating a legacy application incrementally and need new components to work in both the old and new stack simultaneously
- You want CSS encapsulation without a build tool (Shadow DOM encapsulation is built into the browser)
Stick with React/Vue/Svelte components when:
- Your entire application is in a single framework and will stay that way
- You need deep integration with framework-specific features (React Server Components, Vue’s composition API reactivity, Svelte stores)
- Your team has no experience with the Web Components platform APIs and the learning curve cost isn’t justified by the benefits
The interop story between Web Components and React/Vue has improved substantially. It’s no longer an either/or choice — you can have a core component library in Lit and consume it from any framework application in the same organization.
A Realistic Lit Setup
TypeScript + Lit + Vite is the standard setup for a new Web Components project:
npm create vite@latest my-components -- --template lit-ts
cd my-components
npm install
npm run dev
For a component library that will be published:
{
"name": "@my-org/ui-components",
"type": "module",
"exports": {
".": "./dist/index.js",
"./my-button": "./dist/my-button.js"
},
"customElements": "custom-elements.json"
}
The customElements field points to a JSON manifest generated by @custom-elements-manifest/analyzer. This file describes the component’s API — properties, attributes, events, slots, CSS variables — and is consumed by IDE extensions (VS Code’s Custom Elements support), Storybook, and framework wrappers for type inference.
# Generate the manifest
npx @custom-elements-manifest/analyzer analyze --glob "src/**/*.ts"
Storybook has first-class Web Components support. Stories for Lit components look like standard Storybook stories and generate the same interactive documentation.
The Gotchas
Server-side rendering. Shadow DOM is a browser API. Server-rendered HTML doesn’t include it, which means on first load the component is an empty custom element until JavaScript hydrates it. For content-critical components (headings, article text), this can cause layout shifts.
Declarative Shadow DOM (DSD) is the solution — it allows Shadow DOM to be expressed in HTML without JavaScript. Browser support is good (Chrome, Firefox, Safari) but the SSR tooling is newer. Lit supports DSD when paired with @lit-labs/ssr.
Form participation. Custom elements are not form-associated by default. A custom checkbox inside a <form> will not submit its value with the form unless you use the Form-Associated Custom Elements API explicitly:
static formAssociated = true
constructor() {
super()
this._internals = this.attachInternals()
}
// Set the form value manually
_internals.setFormValue(this.checked ? this.value : null)
TypeScript and IDE support. TypeScript doesn’t know about custom element tags in JSX/TSX by default. You need to augment HTMLElementTagNameMap for VS Code’s HTML IntelliSense and TypeScript’s JSX checking to recognize your elements.
None of these are blockers, but they require awareness. Web Components as a platform are mature. The developer experience tooling around them is still catching up to React’s ecosystem.
Sponsored
More from this category
More from Web Development
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored