MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Lit Web Components: Building Framework-Agnostic UI

Build web components with Lit: decorators, reactive properties, and shadow DOM.

LitWeb ComponentsCustom ElementsFrontend

By MinhVo

Introduction

Web Components are the browser-native solution for building reusable UI components that work across any framework—or no framework at all. Lit, maintained by Google, is the most popular library for building Web Components, providing a thin layer of developer experience improvements over the raw Web Components APIs. It adds reactive properties, efficient template rendering with tagged template literals, and a simple component lifecycle that feels familiar to developers coming from React, Vue, or Angular.

The promise of Web Components is write once, use everywhere. A date picker built with Lit works identically in a React application, a Vue project, a legacy jQuery site, or a plain HTML page. This framework-agnostic nature makes Lit ideal for design systems, micro-frontends, and shared component libraries. With browser support reaching near-universal coverage and the API stabilized, Web Components have moved from experimental to production-ready.

Web Components architecture

Understanding Lit Web Components: Core Concepts

Lit builds on three browser-native APIs: Custom Elements, Shadow DOM, and HTML Templates. Custom Elements let you define new HTML tags with their own behavior. Shadow DOM provides encapsulation by creating a isolated DOM tree inside your element, preventing style leakage in both directions. HTML Templates (<template> and <slot>) enable declarative markup with content projection.

A Lit component is a class that extends LitElement, which itself extends HTMLElement. The class defines reactive properties that trigger re-renders when changed, and a render() method that returns a template using Lit's html tagged template literal. Lit's rendering is efficient—it uses a dirty-checking mechanism that only updates the DOM parts that actually changed, not the entire template.

Reactive properties are the core data model. When you declare a property with the @property() decorator, Lit automatically generates observed attributes for HTML attribute binding, handles attribute-to-property conversion, and triggers re-renders when values change. Properties can be primitives, objects, or arrays, with type converters for common formats.

The Shadow DOM boundary is both a feature and a challenge. It provides true style encapsulation—global styles don't leak in, and component styles don't leak out. However, it also means you can't easily style Shadow DOM internals from outside. Lit provides the ::part() pseudo-element and CSS custom properties (design tokens) to bridge this gap while maintaining encapsulation.

Lit components have a well-defined lifecycle: constructor for initialization, connectedCallback when added to the DOM, disconnectedCallback when removed, updated after each render, and firstUpdated after the first render. Understanding this lifecycle is essential for side effects like fetching data, setting up event listeners, and integrating with third-party libraries.

Architecture and Design Patterns

Component Structure

A Lit component follows a consistent structure that separates concerns clearly:

import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
 
@customElement('my-button')
export class MyButton extends LitElement {
    static styles = css`
        :host {
            display: inline-block;
        }
        button {
            padding: 8px 16px;
            border-radius: 4px;
            border: 1px solid #ccc;
            cursor: pointer;
            transition: background-color 0.2s;
        }
        button:hover {
            background-color: #f0f0f0;
        }
        button:active {
            background-color: #e0e0e0;
        }
    `;
 
    @property({ type: String }) variant: 'primary' | 'secondary' = 'primary';
    @property({ type: Boolean }) disabled = false;
    @property({ type: String }) label = '';
 
    @state() private _isLoading = false;
 
    render() {
        return html`
            <button
                class=${this.variant}
                ?disabled=${this.disabled || this._isLoading}
                @click=${this._handleClick}
            >
                ${this._isLoading ? html`<span class="spinner"></span>` : ''}
                ${this.label}
            </button>
        `;
    }
 
    private _handleClick(e: Event) {
        this.dispatchEvent(new CustomEvent('button-click', {
            bubbles: true,
            composed: true,
            detail: { timestamp: Date.now() },
        }));
    }
}

Reactive Properties and State

Lit distinguishes between public properties (decorated with @property()) and internal state (decorated with @state()). Public properties are settable via HTML attributes and JavaScript properties. Internal state is only settable from within the component.

export class DataGrid extends LitElement {
    // Public properties - settable from outside
    @property({ type: Array }) data: any[] = [];
    @property({ type: Number }) pageSize = 10;
    @property({ type: String }) sortColumn = '';
 
    // Internal state - only settable from within
    @state() private _currentPage = 0;
    @state() private _sortDirection: 'asc' | 'desc' = 'asc';
    @state() private _selectedRows: Set<number> = new Set();
 
    // Property change observers
    @property({ type: Array })
    set data(value: any[]) {
        const old = this._data;
        this._data = value;
        this._currentPage = 0; // Reset pagination on data change
        this.requestUpdate('data', old);
    }
    get data(): any[] { return this._data; }
    private _data: any[] = [];
 
    // Computed render based on state
    render() {
        const start = this._currentPage * this.pageSize;
        const pageData = this._sortedData.slice(start, start + this.pageSize);
 
        return html`
            <table>
                <thead>
                    <tr>
                        ${this._renderHeaderCells()}
                    </tr>
                </thead>
                <tbody>
                    ${pageData.map((row, i) => this._renderRow(row, start + i))}
                </tbody>
            </table>
            ${this._renderPagination()}
        `;
    }
}

Template Composition with Slots

Slots enable content projection, allowing parent components to inject content into child component templates:

@customElement('card-component')
export class CardComponent extends LitElement {
    static styles = css`
        :host {
            display: block;
            border: 1px solid #e0e0e0;
            border-radius: 8px;
            overflow: hidden;
        }
        .header {
            padding: 16px;
            border-bottom: 1px solid #e0e0e0;
            font-weight: bold;
        }
        .body {
            padding: 16px;
        }
        .footer {
            padding: 12px 16px;
            background: #f5f5f5;
            border-top: 1px solid #e0e0e0;
        }
    `;
 
    @property({ type: String }) title = '';
 
    render() {
        return html`
            <div class="header">
                <slot name="header">${this.title}</slot>
            </div>
            <div class="body">
                <slot></slot> <!-- Default slot -->
            </div>
            <div class="footer">
                <slot name="footer"></slot>
            </div>
        `;
    }
}
 
// Usage
// <card-component title="User Profile">
//     <img slot="header" src="avatar.png" />
//     <p>Default content goes in the body</p>
//     <div slot="footer">Actions here</div>
// </card-component>

Step-by-Step Implementation

Setting Up a Lit Project

Start a Lit project with the recommended tooling:

# Create project with Lit starter
npm init @lit/lit-element my-lit-project
cd my-lit-project
 
# Or manually with Vite
npm create vite@latest my-lit-project -- --template lit-ts
cd my-lit-project
npm install

The tsconfig.json needs decorators enabled:

{
    "compilerOptions": {
        "target": "ES2021",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "experimentalDecorators": true,
        "useDefineForClassFields": false,
        "lib": ["ES2021", "DOM", "DOM.Iterable"],
        "outDir": "./dist",
        "declaration": true,
        "sourceMap": true
    },
    "include": ["src/**/*.ts"]
}

Building a Complete Component: Date Picker

Here's a production-quality date picker component:

import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
 
@customElement('date-picker')
export class DatePicker extends LitElement {
    static styles = css`
        :host {
            display: inline-block;
            position: relative;
            font-family: system-ui, sans-serif;
        }
        .input-container {
            display: flex;
            align-items: center;
            border: 1px solid #ccc;
            border-radius: 4px;
            padding: 8px 12px;
            cursor: pointer;
        }
        .input-container:focus-within {
            border-color: #1976d2;
            box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
        }
        .calendar {
            position: absolute;
            top: 100%;
            left: 0;
            background: white;
            border: 1px solid #ccc;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            z-index: 1000;
            padding: 16px;
        }
        .calendar-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 12px;
        }
        .days-grid {
            display: grid;
            grid-template-columns: repeat(7, 1fr);
            gap: 4px;
        }
        .day {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 36px;
            height: 36px;
            border-radius: 50%;
            cursor: pointer;
            transition: background-color 0.2s;
        }
        .day:hover { background-color: #e3f2fd; }
        .day.selected { background-color: #1976d2; color: white; }
        .day.today { border: 1px solid #1976d2; }
        .day.other-month { color: #ccc; }
    `;
 
    @property({ type: String }) value = '';
    @property({ type: String }) placeholder = 'Select date';
    @property({ type: Boolean }) showCalendar = false;
 
    @state() private _viewDate = new Date();
    @state() private _selectedDate: Date | null = null;
 
    connectedCallback() {
        super.connectedCallback();
        if (this.value) {
            this._selectedDate = new Date(this.value);
            this._viewDate = new Date(this._selectedDate);
        }
    }
 
    render() {
        return html`
            <div class="input-container" @click=${() => this.showCalendar = !this.showCalendar}>
                <span>${this._selectedDate ? this._formatDate(this._selectedDate) : this.placeholder}</span>
            </div>
            ${this.showCalendar ? this._renderCalendar() : ''}
        `;
    }
 
    private _renderCalendar() {
        const year = this._viewDate.getFullYear();
        const month = this._viewDate.getMonth();
 
        return html`
            <div class="calendar" @click=${(e: Event) => e.stopPropagation()}>
                <div class="calendar-header">
                    <button @click=${this._prevMonth}>&lt;</button>
                    <span>${this._monthName(month)} ${year}</span>
                    <button @click=${this._nextMonth}>&gt;</button>
                </div>
                <div class="days-grid">
                    ${['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => html`<div class="day-name">${d}</div>`)}
                    ${this._renderDays(year, month)}
                </div>
            </div>
        `;
    }
 
    private _renderDays(year: number, month: number) {
        const firstDay = new Date(year, month, 1).getDay();
        const daysInMonth = new Date(year, month + 1, 0).getDate();
        const today = new Date();
 
        const days = [];
        // Previous month days
        for (let i = firstDay - 1; i >= 0; i--) {
            const day = new Date(year, month, -i);
            days.push(this._renderDay(day, true));
        }
        // Current month days
        for (let i = 1; i <= daysInMonth; i++) {
            const day = new Date(year, month, i);
            const isToday = day.toDateString() === today.toDateString();
            const isSelected = this._selectedDate && day.toDateString() === this._selectedDate.toDateString();
            days.push(html`
                <div class="day ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''}"
                     @click=${() => this._selectDate(day)}>
                    ${i}
                </div>
            `);
        }
        return days;
    }
 
    private _selectDate(date: Date) {
        this._selectedDate = date;
        this.value = this._formatDate(date);
        this.showCalendar = false;
        this.dispatchEvent(new CustomEvent('change', {
            detail: { value: this.value, date },
            bubbles: true,
            composed: true,
        }));
    }
 
    private _formatDate(date: Date): string {
        return date.toISOString().split('T')[0];
    }
 
    private _monthName(month: number): string {
        return new Date(2000, month).toLocaleString('default', { month: 'long' });
    }
 
    private _prevMonth() {
        this._viewDate = new Date(this._viewDate.getFullYear(), this._viewDate.getMonth() - 1, 1);
    }
 
    private _nextMonth() {
        this._viewDate = new Date(this._viewDate.getFullYear(), this._viewDate.getMonth() + 1, 1);
    }
}

Using Lit Components in Other Frameworks

Lit components work natively in any framework:

// In React
function App() {
    return (
        <div>
            <date-picker value="2024-01-15" placeholder="Pick a date" />
        </div>
    );
}
 
// In Vue
// <template>
//     <date-picker :value="selectedDate" @change="handleChange" />
// </template>
 
// In Angular
// <date-picker [value]="selectedDate" (change)="handleChange($event)"></date-picker>
 
// In plain HTML
// <date-picker value="2024-01-15"></date-picker>
// <script src="date-picker.js"></script>

Lit development workflow

Real-World Use Cases and Case Studies

Use Case 1: Enterprise Design System

Large organizations with multiple teams using different frameworks benefit from Lit-based design systems. A single Lit component library provides consistent UI across React, Angular, and Vue applications. Salesforce's Lightning Web Components, Adobe's Spectrum Web Components, and Google's Material Web are all built on Lit. The Shadow DOM encapsulation ensures styles don't conflict between the design system and application code.

Use Case 2: Micro-Frontend Architecture

Micro-frontends allow independent teams to deploy UI fragments independently. Lit components serve as the integration layer—each micro-frontend exposes Lit components that the shell application composes. Because Lit components are standard HTML elements, they can be dynamically loaded and composed without framework-specific integration code.

// Shell application loading micro-frontend components
async function loadMicroFrontend(name: string) {
    const module = await import(`./micro-frontends/${name}/index.js`);
    return module.default;
}
 
// Dynamic component loading
const ProductCard = await loadMicroFrontend('product-catalog');
document.querySelector('#catalog').innerHTML = '<product-card></product-card>';

Use Case 3: Progressive Enhancement

Lit components can enhance existing HTML without replacing it. A server-rendered page can be progressively enhanced with interactive Lit components that hydrate static content. This pattern works especially well for forms, data tables, and interactive widgets where the base HTML is functional without JavaScript.

Best Practices for Production

  1. Use CSS custom properties for theming: Define design tokens as CSS custom properties on :host and allow consumers to override them. This provides a clean theming API without breaking encapsulation.

  2. Minimize Shadow DOM depth: Deeply nested Shadow DOM trees make debugging difficult and increase rendering overhead. Keep component hierarchies flat where possible.

  3. Use @state() for internal state: Only use @property() for data that consumers need to set from outside. Use @state() for internal state that drives rendering but isn't part of the public API.

  4. Emit composed events for framework integration: Set composed: true on CustomEvents that need to cross Shadow DOM boundaries. This is essential for framework event binding to work correctly.

  5. Provide TypeScript types: Export interface types for component properties and event details. This improves developer experience in TypeScript projects and enables better IDE support.

  6. Use static get styles() for encapsulated styles: Never use inline styles or global stylesheets. Lit's static styles property ensures styles are scoped to the Shadow DOM and shared across instances efficiently.

  7. Handle attribute/property conversion: Use type converters for non-string attributes. Lit provides built-in converters for Boolean, Number, String, Array, and Object types.

  8. Test with Web Component Tester: Use @open-wc/testing for unit tests and @web/test-runner for cross-browser testing. Test both the public API and edge cases like empty data and rapid property changes.

Common Pitfalls and Solutions

PitfallImpactSolution
Global styles not applyingComponents look unstyledUse CSS custom properties or ::part() for external styling
Properties not reactiveComponent doesn't re-renderEnsure properties are decorated with @property() or @state()
Events not crossing Shadow DOMParent can't hear eventsSet composed: true and bubbles: true on CustomEvents
Large bundle sizeSlow initial loadUse code splitting and dynamic imports for heavy components
SSR compatibility issuesComponents don't render server-sideUse @lit-labs/ssr for server-side rendering
Flash of unstyled contentBrief unstyled displayUse :defined CSS pseudo-class to hide until registered

Performance Optimization

Lit is already optimized for minimal DOM updates, but you can further improve performance:

// Use willUpdate for expensive computations before render
@willUpdate(changedProperties => {
    if (changedProperties.has('data')) {
        this._processedData = this._expensiveProcessing(this.data);
    }
})
private _processedData: any[] = [];
 
// Use requestUpdate batching for multiple property changes
batchPropertyChanges() {
    this.prop1 = 'a';
    this.prop2 = 'b';
    this.prop3 = 'c';
    // Lit batches these into a single render
}
 
// Use shouldUpdate to skip unnecessary renders
shouldUpdate(changedProperties: Map<string, any>) {
    // Only re-render if data or selectedId changed
    return changedProperties.has('data') || changedProperties.has('selectedId');
}

For components with large lists, use virtual scrolling to render only visible items:

@customElement('virtual-list')
export class VirtualList extends LitElement {
    @property({ type: Array }) items: any[] = [];
    @property({ type: Number }) itemHeight = 40;
 
    @state() private _scrollTop = 0;
    @state() private _containerHeight = 0;
 
    render() {
        const startIndex = Math.floor(this._scrollTop / this.itemHeight);
        const endIndex = Math.min(
            startIndex + Math.ceil(this._containerHeight / this.itemHeight) + 1,
            this.items.length
        );
        const visibleItems = this.items.slice(startIndex, endIndex);
 
        return html`
            <div class="container" @scroll=${this._onScroll}
                 style="height: ${this._containerHeight}px; overflow: auto;">
                <div style="height: ${this.items.length * this.itemHeight}px; position: relative;">
                    ${visibleItems.map((item, i) => html`
                        <div style="position: absolute; top: ${(startIndex + i) * this.itemHeight}px; height: ${this.itemHeight}px;">
                            ${this.renderItem(item)}
                        </div>
                    `)}
                </div>
            </div>
        `;
    }
}

Comparison with Alternatives

FeatureLitReactVueStencilVanilla WC
Bundle Size~5KB~42KB~33KB~6KB0KB
Learning CurveLowMediumMediumMediumHigh
Framework AgnosticYesNoNoYesYes
SSR Support@lit-labs/ssrBuilt-inBuilt-inBuilt-inManual
TypeScriptFullFullFullFullManual
EcosystemGrowingLargestLargeGrowingN/A
Shadow DOMDefaultOptionalOptionalDefaultManual

Lit is ideal for shared component libraries and micro-frontends. React/Vue are better for full applications with rich ecosystems. Stencil is an alternative for teams preferring a compiler-based approach. Vanilla Web Components give maximum control but require more boilerplate.

Advanced Patterns and Techniques

Mixins for Shared Behavior

Lit supports mixins for sharing behavior across components:

import { LitElement } from 'lit';
 
type Constructor<T = LitElement> = new (...args: any[]) => T;
 
export declare class FocusableInterface {
    focused: boolean;
    focusVisible: boolean;
}
 
export const Focusable = <T extends Constructor<LitElement>>(superClass: T) => {
    class FocusableElement extends superClass {
        focused = false;
        focusVisible = false;
 
        connectedCallback() {
            super.connectedCallback();
            this.addEventListener('focus', () => this.focused = true);
            this.addEventListener('blur', () => this.focused = false);
            this.addEventListener('focusin', (e) => {
                this.focusVisible = e.composedPath()[0] === this;
            });
        }
    }
    return FocusableElement as Constructor<FocusableInterface> & T;
};
 
// Usage
@customElement('my-input')
export class MyInput extends Focusable(LitElement) {
    render() {
        return html`<input class=${this.focusVisible ? 'focus-visible' : ''} />`;
    }
}

Context API for Dependency Injection

Use Lit's context protocol for dependency injection:

import { createContext, ContextProvider } from '@lit/context';
 
const themeContext = createContext<Theme>('theme');
 
@customElement('theme-provider')
export class ThemeProvider extends LitElement {
    private provider = new ContextProvider(this, themeContext, {
        color: 'light',
        fontSize: 16,
    });
 
    @property({ type: String })
    set theme(value: Theme) {
        this.provider.setValue(value);
    }
}
 
@customElement('themed-button')
export class ThemedButton extends LitElement {
    @consume({ context: themeContext })
    theme: Theme;
 
    render() {
        return html`
            <button style="color: ${this.theme.color}; font-size: ${this.theme.fontSize}px;">
                <slot></slot>
            </button>
        `;
    }
}

Testing Strategies

Use @open-wc/testing for comprehensive component testing:

import { fixture, html, expect } from '@open-wc/testing';
import './my-button.js';
 
describe('my-button', () => {
    it('renders with default properties', async () => {
        const el = await fixture(html`<my-button label="Click me"></my-button>`);
        expect(el.shadowRoot.querySelector('button').textContent.trim()).to.equal('Click me');
    });
 
    it('dispatches click event', async () => {
        const el = await fixture(html`<my-button label="Click"></my-button>`);
        let fired = false;
        el.addEventListener('button-click', () => fired = true);
        el.shadowRoot.querySelector('button').click();
        expect(fired).to.be.true;
    });
 
    it('reflects disabled attribute', async () => {
        const el = await fixture(html`<my-button disabled></my-button>`);
        expect(el.disabled).to.be.true;
        expect(el.shadowRoot.querySelector('button').disabled).to.be.true;
    });
 
    it('applies variant styles', async () => {
        const el = await fixture(html`<my-button variant="primary"></my-button>`);
        expect(el.variant).to.equal('primary');
    });
});

Future Outlook

Web Components are gaining momentum with new browser features like CSS @scope, Declarative Shadow DOM for SSR, and the Scoped Custom Element Registry. Lit 3.0 continues improving developer experience with better TypeScript support and smaller bundle sizes. The Web Components Community Group is standardizing patterns for accessibility, form participation, and internationalization. Browser vendors are investing heavily in Web Components performance, with Chrome, Firefox, and Safari all optimizing Shadow DOM rendering.

Conclusion

Lit provides the best developer experience for building Web Components without sacrificing the framework-agnostic benefits that make Web Components valuable. Its reactive properties, efficient rendering, and familiar API patterns make it accessible to developers from any framework background. Whether you're building a design system, micro-frontend architecture, or shared component library, Lit delivers production-quality components that work everywhere.

Key takeaways: use @property() for public API and @state() for internal state, leverage CSS custom properties for theming, and emit composed events for framework integration. Test with @open-wc/testing, use TypeScript for better DX, and keep Shadow DOM hierarchies flat. The investment in Web Components pays off through true framework independence and browser-native performance.

For further reading, consult the Lit documentation, the Open Web Components recommendations, and the Web Components specification for understanding the underlying browser APIs.


Lit Web Components