Lit + TypeScript: Web Components the Way They Should Be
Kim Boender
Have you ever looked at the raw Web Components API and thought "this should be better"? You're not wrong. The browser's native component model has been around for years, but the developer experience around it was genuinely painful for a long time. That changed when Lit arrived.
Combine Lit with TypeScript, and you get something genuinely interesting: framework-agnostic, standards-based components with the type safety and developer ergonomics modern teams expect. Let me walk you through what Lit is, why it matters, and how TypeScript makes it shine.
What is Lit?
Lit is a lightweight library from Google that makes building Web Components fast and enjoyable. It's built on top of native browser APIs, Custom Elements, Shadow DOM, and HTML Templates, which means components built with Lit work anywhere HTML works. No framework dependency. No virtual DOM overhead. Just the platform.
The library itself is tiny: under 6KB minified and gzipped. And because it compiles to standard Web Components, you can drop a Lit component into a Vue app, a React app, a plain HTML page, or a server-rendered document. That's something no framework component can claim.
Setting up Lit with TypeScript
Getting started is straightforward. Install the packages:
npm install lit
npm install --save-dev typescriptA minimal tsconfig.json for Lit looks like this:
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2020",
"moduleResolution": "node",
"strict": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"lib": ["ES2021", "DOM", "DOM.Iterable"]
}
}Note experimentalDecorators: true. Lit uses decorators heavily, and they're what make the authoring experience so clean.
Your first Lit component in TypeScript
Here's a simple counter component:
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('my-counter')
export class MyCounter extends LitElement {
static styles = css`
:host { display: block; font-family: sans-serif; }
button { padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; }
p { font-size: 1.25rem; }
`;
@state() private count = 0;
render() {
return html`
<p>Count: ${this.count}</p>
<button @click=${this._increment}>Increment</button>
`;
}
private _increment() {
this.count++;
}
}Drop <my-counter></my-counter> into any HTML page, and it works. No build pipeline required for consumption. No framework runtime on the receiving end.
Decorators: the TypeScript sweet spot
Lit's decorator API is where TypeScript really earns its keep. Here are the key ones.
@customElement registers your class as a custom element. TypeScript will enforce that you've extended LitElement correctly.
@property declares a public reactive property that triggers a re-render when changed and can be set as an HTML attribute. With TypeScript, you get full type inference on these. If you set name to a number somewhere, the compiler catches it immediately.
@state declares private reactive state. Same reactivity as @property, but not exposed to the outside world.
@query is a convenience decorator for querying shadow DOM elements: typed, clean, no manual shadowRoot.querySelector calls.
Typed props and events: building a real component
Here's a more realistic example: a typed card component that emits custom events:
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
export interface CardSelectEvent {
id: string;
title: string;
}
@customElement('project-card')
export class ProjectCard extends LitElement {
static styles = css`
:host {
display: block;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1.5rem;
cursor: pointer;
transition: box-shadow 0.2s;
}
:host(:hover) { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
h2 { margin: 0 0 0.5rem; }
p { margin: 0; color: #64748b; }
`;
@property({ type: String }) cardId = '';
@property({ type: String }) title = '';
@property({ type: String }) description = '';
render() {
return html`
<h2>${this.title}</h2>
<p>${this.description}</p>
`;
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this._handleClick);
}
private _handleClick = () => {
const event = new CustomEvent<CardSelectEvent>('card-select', {
detail: { id: this.cardId, title: this.title },
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
};
}The composed: true flag is important. It lets the event bubble out of the Shadow DOM boundary so parent frameworks can listen for it normally.
Shadow DOM and encapsulation
One of Lit's most powerful features is Shadow DOM encapsulation. Styles defined in a Lit component are scoped to that component by default. No CSS leaks in, no CSS leaks out.
This is a real win if you've ever dealt with stylesheet conflicts in large applications. Your button styles won't affect anything outside the component, and external global styles won't accidentally override your internals.
You can still allow controlled styling from the outside using CSS custom properties:
static styles = css`
:host {
--btn-bg: #3b82f6;
--btn-color: white;
}
button {
background: var(--btn-bg);
color: var(--btn-color);
}
`;Consumers can then override those variables without needing to know anything about your internal markup.
When would I actually use Lit?
As someone who primarily works in Vue and React, Lit isn't my first choice for a full application. But there are real scenarios where it shines.
Design systems and component libraries are the obvious one. If you're building a component library that needs to work across multiple frameworks, or you want to stop rewriting the same button component in Vue, React, and Angular, Lit gives you one implementation that works everywhere.
Micro-frontends are another good fit. When different teams own different parts of an application and use different stacks, Web Components are a natural boundary. Lit makes those components production-quality.
Embedding widgets in third-party contexts also benefits enormously from Shadow DOM encapsulation and zero framework requirements. Marketing pages, embeddable tools, and dashboard widgets that need to be dropped into arbitrary environments are natural use cases.
Progressive enhancement on existing sites is another good fit. Lit components load fast, work without a JavaScript framework, and degrade gracefully.
Lit vs the frameworks: an honest take
Lit is not a replacement for Vue or React. It doesn't have routing, doesn't have a full-featured state management story, and its ecosystem is much smaller. If you're building a complex SPA, Vue 3 or Next.js will serve you better.
But Lit isn't trying to be those things. It's trying to be the best way to build Web Components, and at that, it succeeds. The fact that those components then work in any framework, in any context, with no runtime overhead? That's a genuine superpower.
For those of us who care about the long-term health of the web, there's also something satisfying about using the platform rather than papering over it. Web Components are a standard. Lit components today will still work in browsers ten years from now, long after the framework du jour has been replaced by something else.
Getting started
The official Lit docs at lit.dev are excellent and well-maintained. There's a Playground built right into the site where you can experiment with components live before setting up a local environment.
For a TypeScript-ready starter, the Lit team maintains project starters via npm init @open-wc. It scaffolds a full Lit + TypeScript project with testing, linting, and a dev server out of the box.
npm init @open-wcChoose "Web Component" and "TypeScript", and you're up and running in minutes.
Final thoughts
Lit with TypeScript hits a sweet spot that not many libraries do: it's small, standards-based, and genuinely pleasant to work with. The decorator API feels natural to anyone coming from Angular or modern TypeScript, the Shadow DOM encapsulation solves real problems, and the ability to ship components that work everywhere is uniquely valuable.
If you've written off Web Components because of the raw API, give Lit another look. It's the library that makes the browser's native component model actually usable.