The Case for Web Components with Lit
Hey there! Take a seat, grab a coffee, and let's chat about Web Components. Whether you're new to them or just need a refresh, they offer a powerful, framework-agnostic way to build reusable UI. In this article for TypeScript.guru, we'll explore how Lit simplifies the creation of Web Components—so you can spend more time crafting great user experiences and less time wrangling setup code.
Why Web Components?
1. Native Browser Support
Web Components harness built-in browser standards—Custom Elements, Shadow DOM, and HTML Templates. This means you don't need extra libraries or frameworks just to render a custom element. Less overhead equals faster load times and fewer compatibility headaches.
2. Strong Encapsulation
With the Shadow DOM, each component keeps its styles and DOM structure separate from the rest of your app. No more accidentally overriding styles or messing up layouts elsewhere—your component is an island!
3. Framework-Agnostic
Because Web Components conform to browser APIs, you can use them anywhere. Drop them into a React app, an Angular project, or a plain HTML page—no special wrappers required.
4. Scalability and Maintainability
With each piece of UI encapsulated, it's easier to scale large projects. You can create and maintain hundreds of these self-contained elements without the typical monolithic overhead.
Introducing Lit
Lit is a lightweight library created to make building Web Components simpler and more efficient. Here's what Lit brings to the table:
- Reactive Properties: Define properties on your component, and Lit automatically watches for changes and updates the DOM.
- Declarative Templates: Write neat, template-literal-based HTML (in JavaScript or TypeScript) that's expressive yet concise.
- Fast Updates: Lit focuses on updating only what changes, making it very performant—no heavy virtual DOM needed.
- TypeScript First: Lit is built with TypeScript support from the ground up, offering excellent type safety.
- Directives System: Extend the template syntax with powerful custom directives.
- Lifecycle Hooks: React to component changes with predictable lifecycle methods.
TypeScript and Lit: A Perfect Match
Lit's focus on simplicity pairs wonderfully with TypeScript's static typing. Let's look at the advantages:
- Type-Safe Properties: Declare properties with proper types for better autocomplete and error catching
- Interface-Driven Development: Define clear interfaces for your component props and events
- Enhanced IDE Support: Get better tooling with proper type definitions
- Compile-Time Validation: Catch errors before runtime
Property Types in Lit + TypeScript
Decorator | Purpose | TypeScript Benefit |
---|---|---|
@property() |
Basic reactive property | Type inference for primitives |
@state() |
Internal state (not exposed as attribute) | Private state with type safety |
@query() |
Element reference | Type-safe DOM references |
@queryAll() |
Multiple element references | Type-safe NodeList references |
@eventOptions() |
Configure event listeners | Type checking for options |
Lit in Action: A Comprehensive TypeScript Example
Let's build a more feature-rich example that showcases Lit with TypeScript:
import { LitElement, html, css, PropertyValues } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
// Type definitions improve developer experience
export interface User {
id: number;
name: string;
role: 'admin' | 'user' | 'guest';
joinDate: Date;
}
export interface CardClickEvent {
userId: number;
action: 'view' | 'edit';
}
@customElement('user-card')
export class UserCard extends LitElement {
// Type-safe properties with default values
@property({ type: Object })
public user: User = {
id: 0,
name: 'Guest User',
role: 'guest',
joinDate: new Date()
};
@property({ type: Boolean, reflect: true })
public expanded = false;
@property({ type: String })
public theme: 'light' | 'dark' = 'light';
// Internal state with TypeScript
@state()
private _isHovering = false;
// Type-safe element reference
@query('.card-title')
private _titleElement!: HTMLElement;
// Lifecycle methods
connectedCallback(): void {
super.connectedCallback();
console.log('Component connected to DOM');
}
// Use PropertyValues type for correct typing
updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('user')) {
console.log('User changed:', this.user);
}
}
// Computed property with proper typing
private get roleColor(): string {
const colors: Record<User['role'], string> = {
admin: '#e74c3c',
user: '#3498db',
guest: '#95a5a6'
};
return colors[this.user.role];
}
// Event handlers with proper typing
private handleViewClick(): void {
this._dispatchCardEvent('view');
}
private handleEditClick(): void {
this._dispatchCardEvent('edit');
}
private _dispatchCardEvent(action: CardClickEvent['action']): void {
const eventDetail: CardClickEvent = {
userId: this.user.id,
action
};
this.dispatchEvent(new CustomEvent<CardClickEvent>('card-action', {
detail: eventDetail,
bubbles: true,
composed: true
}));
}
// Using directives with TypeScript
public render() {
// Type-safe class and style maps
const cardClasses = {
'card': true,
'card--expanded': this.expanded,
'card--dark': this.theme === 'dark'
};
const titleStyles = {
color: this.roleColor,
fontWeight: this.expanded ? 'bold' : 'normal'
};
const formattedDate = this.user.joinDate.toLocaleDateString();
return html`
<div
class=${classMap(cardClasses)}
@mouseenter=${() => this._isHovering = true}
@mouseleave=${() => this._isHovering = false}
>
<div class="card-header">
<h3 class="card-title" style=${styleMap(titleStyles)}>
${this.user.name}
</h3>
<span class="card-role">${this.user.role}</span>
</div>
<div class="card-body">
<p>User ID: ${this.user.id}</p>
<p>Joined: ${formattedDate}</p>
${this._isHovering ? html`
<div class="card-actions">
<button @click=${this.handleViewClick}>View</button>
<button @click=${this.handleEditClick}>Edit</button>
</div>
` : ''}
</div>
<!-- Slot for content projection -->
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
`;
}
public static styles = css`
.card {
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 16px;
transition: all 0.3s ease;
background: white;
margin: 16px;
}
.card--expanded {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: translateY(-4px);
}
.card--dark {
background: #2c3e50;
color: #ecf0f1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-title {
margin: 0;
font-size: 1.2rem;
}
.card-role {
font-size: 0.8rem;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 16px;
background: #f1f1f1;
}
.card-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
button {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
background: #3498db;
color: white;
transition: background 0.2s;
}
button:hover {
background: #2980b9;
}
`;
}
Component Composition with Slots
One of Lit's most powerful features is the ability to compose components using slots. This HTML standard allows you to create component "templates" with placeholder slots that consumers can fill with their own content.
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('card-container')
export class CardContainer extends LitElement {
static styles = css`
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.card-header {
background: #f5f5f5;
padding: 16px;
border-bottom: 1px solid #ddd;
}
.card-body {
padding: 16px;
}
.card-footer {
padding: 16px;
background: #f5f5f5;
border-top: 1px solid #ddd;
}
`;
render() {
return html`
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot>Default body content</slot>
</div>
<div class="card-footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
`;
}
}
You can then use this composable component like this:
<card-container>
<h2 slot="header">User Profile</h2>
<p>This is the main content of the card.</p>
<div slot="footer">
<button>Save</button>
<button>Cancel</button>
</div>
</card-container>
State Management with Reactive Controllers
Lit 2.0 introduced Reactive Controllers, a powerful pattern for extracting and reusing stateful logic:
import { ReactiveController, ReactiveControllerHost } from 'lit';
// Define a type-safe fetch controller
export class FetchController<T> implements ReactiveController {
host: ReactiveControllerHost;
private _data: T | null = null;
private _error: Error | null = null;
private _loading = false;
constructor(host: ReactiveControllerHost, private url: string) {
this.host = host;
this.host.addController(this);
}
hostConnected() {
this.fetchData();
}
async fetchData() {
this._loading = true;
this._error = null;
this.host.requestUpdate();
try {
const response = await fetch(this.url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
this._data = await response.json() as T;
} catch (e) {
this._error = e instanceof Error ? e : new Error(String(e));
} finally {
this._loading = false;
this.host.requestUpdate();
}
}
get data() { return this._data; }
get error() { return this._error; }
get loading() { return this._loading; }
}
Using the controller in a component:
@customElement('user-list')
export class UserList extends LitElement {
// Type-safe fetch controller
private userController = new FetchController<User[]>(this, '/api/users');
render() {
const { data, error, loading } = this.userController;
return html`
${loading ? html`<p>Loading users...</p>` : ''}
${error ? html`<p>Error: ${error.message}</p>` : ''}
${data ? html`
<ul>
${data.map(user => html`
<li>
<user-card .user=${user}>
<span slot="footer">Last login: ${new Date().toLocaleDateString()}</span>
</user-card>
</li>
`)}
</ul>
` : ''}
`;
}
}
Testing Web Components
Lit components are easy to test thanks to their standard-compliant foundation. Here's an example using the modern web test runner:
import { html, fixture, expect } from '@open-wc/testing';
import { UserCard } from '../src/UserCard.js';
describe('UserCard', () => {
it('renders with default properties', async () => {
const el = await fixture<UserCard>(html`<user-card></user-card>`);
expect(el.user.name).to.equal('Guest User');
expect(el.shadowRoot!.querySelector('.card-title')!.textContent).to.contain('Guest User');
});
it('reflects expanded attribute', async () => {
const el = await fixture<UserCard>(html`<user-card .expanded=${true}></user-card>`);
expect(el.expanded).to.be.true;
expect(el.shadowRoot!.querySelector('.card')!.classList.contains('card--expanded')).to.be.true;
});
it('dispatches card-action event when view button is clicked', async () => {
const el = await fixture<UserCard>(html`<user-card></user-card>`);
let eventDetail: any;
el.addEventListener('card-action', ((e: CustomEvent) => {
eventDetail = e.detail;
}) as EventListener);
const viewButton = el.shadowRoot!.querySelector('button')!;
viewButton.click();
expect(eventDetail).to.exist;
expect(eventDetail.action).to.equal('view');
});
});
Performance Optimizations
Lit is already optimized for performance, but there are additional steps you can take:
-
Efficient Property Updates: Use the
hasChanged
option to avoid unnecessary rerenders@property({ hasChanged: (newVal, oldVal) => newVal.id !== oldVal.id || newVal.version > oldVal.version }) item = { id: 0, version: 0, data: {} };
-
Template Caching: For complex templates or loops
// Cache template parts in a WeakMap const templateCache = new WeakMap(); render() { return html` <div> ${this.items.map(item => { let template = templateCache.get(item); if (!template) { template = this.renderItem(item); templateCache.set(item, template); } return template; })} </div> `; }
-
Directive Usage: Use Lit's built-in directives like
repeat
for optimized rendering of listsimport { repeat } from 'lit/directives/repeat.js'; render() { return html` <ul> ${repeat( this.items, (item) => item.id, // Key function (item) => html`<li>${item.name}</li>` )} </ul> `; }
Framework Integration
One of the biggest advantages of Web Components is their interoperability. Here's how to use them with popular frameworks:
React
import React, { useRef, useEffect } from 'react';
import '../components/user-card';
// Define props interface that matches the Web Component's properties
interface UserCardProps {
user: {
id: number;
name: string;
role: 'admin' | 'user' | 'guest';
joinDate: Date;
};
expanded?: boolean;
theme?: 'light' | 'dark';
onCardAction?: (event: CustomEvent) => void;
}
export const UserCardWrapper: React.FC<UserCardProps> = ({
user,
expanded = false,
theme = 'light',
onCardAction
}) => {
const cardRef = useRef<HTMLElement>(null);
useEffect(() => {
const card = cardRef.current;
if (card && onCardAction) {
card.addEventListener('card-action', onCardAction as EventListener);
return () => {
card.removeEventListener('card-action', onCardAction as EventListener);
};
}
}, [onCardAction]);
return (
<user-card
ref={cardRef as any}
user={user}
expanded={expanded}
theme={theme}
>
<div slot="footer">Integrated with React</div>
</user-card>
);
};
Vue
<template>
<user-card
:user="user"
:expanded="expanded"
:theme="theme"
@card-action="handleCardAction"
>
<template v-slot:footer>
<span>Integrated with Vue</span>
</template>
</user-card>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import '../components/user-card';
interface User {
id: number;
name: string;
role: 'admin' | 'user' | 'guest';
joinDate: Date;
}
export default defineComponent({
props: {
user: {
type: Object as PropType<User>,
required: true
},
expanded: Boolean,
theme: {
type: String as PropType<'light' | 'dark'>,
default: 'light'
}
},
methods: {
handleCardAction(e: CustomEvent) {
this.$emit('action', e.detail);
}
}
});
</script>
Real-World Use Cases
Lit and Web Components shine in several key scenarios:
Design Systems
Web Components are perfect for design systems because they work across frameworks. Companies like Adobe, IBM, and Salesforce have all built design systems based on Web Components.
// A design system button component example
@customElement('ds-button')
export class DSButton extends LitElement {
@property({ type: String })
variant: 'primary' | 'secondary' | 'danger' = 'primary';
@property({ type: Boolean })
disabled = false;
@property({ type: Boolean })
loading = false;
static styles = css`
/* Design token variables */
:host {
--primary-color: var(--ds-primary-color, #3498db);
--secondary-color: var(--ds-secondary-color, #95a5a6);
--danger-color: var(--ds-danger-color, #e74c3c);
display: inline-block;
}
button {
font-family: var(--ds-font-family, sans-serif);
font-size: var(--ds-font-size-m, 14px);
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.primary {
background-color: var(--primary-color);
color: white;
}
.secondary {
background-color: var(--secondary-color);
color: white;
}
.danger {
background-color: var(--danger-color);
color: white;
}
.loading::after {
content: "";
display: inline-block;
width: 10px;
height: 10px;
margin-left: 8px;
border: 2px solid currentColor;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`;
render() {
return html`
<button
class="${this.variant} ${this.loading ? 'loading' : ''}"
?disabled="${this.disabled || this.loading}"
@click="${this._handleClick}"
>
<slot></slot>
</button>
`;
}
private _handleClick(e: Event) {
if (this.disabled || this.loading) {
e.preventDefault();
return;
}
this.dispatchEvent(new CustomEvent('ds-click', {
bubbles: true,
composed: true
}));
}
}
Micro-Frontends
Web Components provide natural boundaries between different parts of your application, making them ideal for micro-frontend architectures.
// A micro-frontend shell component
@customElement('app-shell')
export class AppShell extends LitElement {
@property({ type: String })
currentRoute = '';
@state()
private _routes: Record<string, string> = {
'/': 'home-module',
'/profile': 'profile-module',
'/settings': 'settings-module'
};
connectedCallback() {
super.connectedCallback();
window.addEventListener('popstate', this._handleRouteChange);
this._handleRouteChange();
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('popstate', this._handleRouteChange);
}
private _handleRouteChange = () => {
this.currentRoute = window.location.pathname;
}
private _navigateTo(path: string) {
window.history.pushState({}, '', path);
this.currentRoute = path;
}
render() {
const currentModule = this._routes[this.currentRoute] || 'not-found-module';
return html`
<nav>
<ul>
<li><a href="/" @click=${(e: Event) => { e.preventDefault(); this._navigateTo('/'); }}>Home</a></li>
<li><a href="/profile" @click=${(e: Event) => { e.preventDefault(); this._navigateTo('/profile'); }}>Profile</a></li>
<li><a href="/settings" @click=${(e: Event) => { e.preventDefault(); this._navigateTo('/settings'); }}>Settings</a></li>
</ul>
</nav>
<main>
<!-- Dynamically load the appropriate module -->
<${unsafeStatic(currentModule)}></${unsafeStatic(currentModule)}>
</main>
`;
}
}
Best Practices for Lit + TypeScript
To get the most out of Lit with TypeScript, follow these guidelines:
-
Define Strong Types
// Define explicit interfaces for component properties interface ButtonProps { variant: 'primary' | 'secondary'; size: 'small' | 'medium' | 'large'; disabled: boolean; } // Apply them to your component @customElement('my-button') class MyButton extends LitElement implements ButtonProps { @property({ type: String }) variant: ButtonProps['variant'] = 'primary'; @property({ type: String }) size: ButtonProps['size'] = 'medium'; @property({ type: Boolean }) disabled = false; // Implementation... }
-
Type Your Events
// Define event types interface MyEvents { 'button-click': CustomEvent<{ id: string }>; 'hover-change': CustomEvent<{ isHovering: boolean }>; } // Extend EventTarget for better typing declare global { interface HTMLElementEventMap { 'button-click': MyEvents['button-click']; 'hover-change': MyEvents['hover-change']; } }
-
Use Generics for Reusable Components
@customElement('data-list') export class DataList<T extends { id: string | number }> extends LitElement { @property({ type: Array }) items: T[] = []; @property() renderItem: (item: T) => TemplateResult = () => html``; render() { return html` <ul> ${this.items.map(item => html` <li key=${item.id}>${this.renderItem(item)}</li> `)} </ul> `; } }
Let us reiterate:
Web Components with Lit provide a powerful, future-proof approach to building UI components. With TypeScript integration, you get all the benefits of type safety while creating reusable elements that work anywhere.
Key takeaways:
- Standard-Based: Built on browser standards, not framework conventions
- TypeScript-Friendly: First-class TypeScript support for better tooling
- Performant: Optimized rendering with minimal overhead
- Interoperable: Works with any framework or no framework at all
- Scalable: Perfect for design systems and micro-frontends
Ready to get started? Check out the official Lit documentation and the TypeScript handbook to begin building your web components.
Also, check out the catalog tool I built: