Pavan Rangani

HomeBlogWeb Components with Lit: Building Framework-Agnostic UI Libraries for Production

Web Components with Lit: Building Framework-Agnostic UI Libraries for Production

By Pavan Rangani · March 22, 2026 · Web Development

Web Components with Lit: Building Framework-Agnostic UI Libraries for Production

Web Components with Lit Framework

Web Components Lit framework development has matured into a first-class approach for building reusable, framework-agnostic UI libraries. Lit 4.0, released in early 2026, brings significant performance improvements, enhanced SSR support, and a developer experience that rivals React and Vue — while producing components that work everywhere HTML works. Because the output is a standard Custom Element, the same button or table renders in a React dashboard, an Angular admin panel, and a server-rendered marketing page without rewrites.

This guide covers building a production design system with Lit, from individual components to theming infrastructure to distribution. Moreover, you will learn how these components integrate seamlessly into React, Angular, Vue, and vanilla HTML applications without wrapper libraries or compatibility layers. Throughout, the focus stays on patterns that survive real production use — accessibility, theming that pierces Shadow DOM, and distribution that does not break consumers.

Why Web Components in 2026

The frontend framework landscape continues to fragment. React 19, Vue 3, Angular 19, Svelte 5, and Solid.js all compete for developer attention. If your organization builds shared UI components, committing to any single framework means those components cannot be used by teams on different stacks. Additionally, framework migrations — which industry surveys suggest happen every three to five years — require rewriting your entire component library when it is tied to one runtime.

Web Components solve this by building on browser-native standards: Custom Elements, Shadow DOM, and HTML Templates. A Lit component is a standard Custom Element that works in any environment — including server-rendered pages, WordPress sites, and legacy jQuery applications. Consequently, large vendors such as GitHub, Adobe (Spectrum), and SAP (UI5) ship their design systems as Web Components precisely because their consumers span many unrelated codebases.

Web component development and coding
Building framework-agnostic components with web standards

Web Components Lit Framework: Building Your First Component

# Initialize a Lit project
npm create lit@latest my-design-system
cd my-design-system
npm install
// src/components/ds-button.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';

@customElement('ds-button')
export class DsButton extends LitElement {
  static styles = css`
    :host {
      display: inline-flex;
    }

    :host([hidden]) {
      display: none;
    }

    button {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: var(--ds-button-padding, 10px 20px);
      border: none;
      border-radius: var(--ds-radius-md, 8px);
      font-family: var(--ds-font-family, system-ui);
      font-size: var(--ds-font-size-md, 14px);
      font-weight: 600;
      cursor: pointer;
      transition: all 0.2s ease;
    }

    button.primary {
      background: var(--ds-color-primary, #3b82f6);
      color: white;
    }

    button.primary:hover {
      background: var(--ds-color-primary-hover, #2563eb);
      transform: translateY(-1px);
      box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
    }

    button.secondary {
      background: transparent;
      color: var(--ds-color-primary, #3b82f6);
      border: 2px solid currentColor;
    }

    button.danger {
      background: var(--ds-color-danger, #ef4444);
      color: white;
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
      transform: none;
      box-shadow: none;
    }

    button.loading {
      position: relative;
      color: transparent;
    }

    .spinner {
      position: absolute;
      width: 16px;
      height: 16px;
      border: 2px solid rgba(255,255,255,0.3);
      border-top-color: white;
      border-radius: 50%;
      animation: spin 0.6s linear infinite;
    }

    @keyframes spin {
      to { transform: rotate(360deg); }
    }
  `;

  @property({ type: String }) variant: 'primary' | 'secondary' | 'danger' = 'primary';
  @property({ type: Boolean }) disabled = false;
  @property({ type: Boolean }) loading = false;
  @property({ type: String }) size: 'sm' | 'md' | 'lg' = 'md';

  render() {
    const classes = {
      [this.variant]: true,
      loading: this.loading,
    };

    return html`
      <button
        class=${classMap(classes)}
        ?disabled=${this.disabled || this.loading}
        @click=${this._handleClick}
      >
        ${this.loading ? html`<span class="spinner"></span>` : ''}
        <slot></slot>
      </button>
    `;
  }

  private _handleClick(e: Event) {
    if (this.loading || this.disabled) {
      e.stopPropagation();
      return;
    }
    this.dispatchEvent(new CustomEvent('ds-click', {
      bubbles: true,
      composed: true,
      detail: { originalEvent: e }
    }));
  }
}

Reactive Properties, Attributes, and the Update Lifecycle

Understanding how Lit reacts to change is the single most important concept for writing correct components. When you decorate a field with @property, Lit creates an accessor that schedules an asynchronous re-render whenever the value changes. Crucially, the update is batched: setting three properties in the same tick triggers exactly one render, which keeps high-frequency updates cheap.

The type option controls attribute reflection. A Boolean property maps to attribute presence, a Number is parsed from the string attribute, and an Object or Array is JSON-parsed. For internal state that should never become an attribute, use @state() instead — it triggers re-renders without polluting the DOM with serialized values. A common pitfall is mutating an array in place: because Lit compares by reference, this.items.push(x) will not re-render. Instead, assign a new reference with this.items = [...this.items, x].

For side effects, override the lifecycle hooks rather than reaching into the DOM ad hoc. Use willUpdate() to derive computed state before rendering, updated(changedProperties) to read measured layout after the DOM settles, and firstUpdated() for one-time setup such as attaching an IntersectionObserver. Reading changedProperties lets you run expensive work only when the relevant input actually changed.

Theming with CSS Custom Properties

Therefore, a robust theming system is essential for any design system. Lit components use CSS Custom Properties (CSS variables) which pierce through Shadow DOM boundaries, allowing consumers to customize appearance without modifying component internals. This inheritance is the key escape hatch: styles inside a shadow root are otherwise fully encapsulated, so variables are the sanctioned channel for external customization.

// src/themes/tokens.ts
export const lightTheme = css`
  :root {
    /* Colors */
    --ds-color-primary: #3b82f6;
    --ds-color-primary-hover: #2563eb;
    --ds-color-danger: #ef4444;
    --ds-color-success: #22c55e;
    --ds-color-warning: #f59e0b;
    --ds-color-bg: #ffffff;
    --ds-color-surface: #f8fafc;
    --ds-color-text: #0f172a;
    --ds-color-text-muted: #64748b;
    --ds-color-border: #e2e8f0;

    /* Typography */
    --ds-font-family: 'Inter', system-ui, sans-serif;
    --ds-font-size-sm: 12px;
    --ds-font-size-md: 14px;
    --ds-font-size-lg: 16px;
    --ds-font-size-xl: 20px;

    /* Spacing */
    --ds-space-xs: 4px;
    --ds-space-sm: 8px;
    --ds-space-md: 16px;
    --ds-space-lg: 24px;
    --ds-space-xl: 32px;

    /* Radius */
    --ds-radius-sm: 4px;
    --ds-radius-md: 8px;
    --ds-radius-lg: 12px;
    --ds-radius-full: 9999px;

    /* Shadows */
    --ds-shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
    --ds-shadow-md: 0 4px 6px rgba(0,0,0,0.07);
    --ds-shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
  }
`;

export const darkTheme = css`
  :root[data-theme="dark"] {
    --ds-color-primary: #60a5fa;
    --ds-color-primary-hover: #93bbfd;
    --ds-color-bg: #0f172a;
    --ds-color-surface: #1e293b;
    --ds-color-text: #f1f5f9;
    --ds-color-text-muted: #94a3b8;
    --ds-color-border: #334155;
  }
`;

For deeper customization than variables allow, expose ::part() selectors. Tagging an internal element with part="control" lets consumers style it from the outside — for example, ds-button::part(control) { letter-spacing: 0.5px; } — without you exposing the entire internal structure. Parts are the contract you opt into deliberately, which is far safer than letting consumers reach into your shadow tree.

Responsive web design and theming
Design system theming with CSS Custom Properties

Complex Component Patterns

Consequently, real design systems need complex interactive components like data tables, modals, and form controls. Lit handles these with reactive controllers and the context protocol for state sharing. Reactive controllers — small objects that hook into a host’s lifecycle — let you package reusable behavior such as keyboard navigation or async data loading without inheritance, which keeps components composable.

// src/components/ds-data-table.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';

interface Column<T> {
  key: keyof T;
  label: string;
  sortable?: boolean;
  render?: (value: any, row: T) => unknown;
}

@customElement('ds-data-table')
export class DsDataTable<T extends Record<string, any>> extends LitElement {
  @property({ type: Array }) columns: Column<T>[] = [];
  @property({ type: Array }) data: T[] = [];
  @property({ type: Boolean }) selectable = false;

  @state() private _sortColumn: string | null = null;
  @state() private _sortDirection: 'asc' | 'desc' = 'asc';
  @state() private _selectedRows: Set<number> = new Set();

  private get sortedData(): T[] {
    if (!this._sortColumn) return this.data;
    return [...this.data].sort((a, b) => {
      const aVal = a[this._sortColumn!];
      const bVal = b[this._sortColumn!];
      const cmp = String(aVal).localeCompare(String(bVal));
      return this._sortDirection === 'asc' ? cmp : -cmp;
    });
  }

  private _toggleSort(column: string) {
    if (this._sortColumn === column) {
      this._sortDirection = this._sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
      this._sortColumn = column;
      this._sortDirection = 'asc';
    }
  }

  render() {
    return html`
      <table>
        <thead>
          <tr>
            ${this.columns.map(col => html`
              <th @click=${col.sortable ? () => this._toggleSort(String(col.key)) : null}>
                ${col.label}
                ${col.sortable && this._sortColumn === col.key
                  ? this._sortDirection === 'asc' ? '↑' : '↓' : ''}
              </th>
            `)}
          </tr>
        </thead>
        <tbody>
          ${repeat(this.sortedData, (_, i) => i, (row, index) => html`
            <tr class=${this._selectedRows.has(index) ? 'selected' : ''}>
              ${this.columns.map(col => html`
                <td>${col.render ? col.render(row[col.key], row) : row[col.key]}</td>
              `)}
            </tr>
          `)}
        </tbody>
      </table>
    `;
  }
}

Notice the use of the repeat directive keyed by a stable identity. For large lists, repeat moves existing DOM nodes instead of re-creating them, which preserves focus and input state during sorts and filters. By contrast, a plain .map() is cheaper for small static lists where reordering never happens. Choosing the right directive is the kind of decision that separates a smooth table from one that drops keystrokes mid-edit.

Accessibility and Forms: The ElementInternals API

A button that looks right but is invisible to a screen reader is a defect, not a feature. Web Components do not get accessibility for free — you must put roles and ARIA on the real interactive elements inside the shadow root. Rendering a native <button> rather than a styled <div>, as the example above does, gives you keyboard focus, Enter and Space activation, and the correct role automatically.

Forms are the harder problem. A native input inside a shadow root does not submit with the surrounding <form> by default, because the form boundary does not cross into the shadow tree. The fix is the ElementInternals API, which lets a custom element participate in form submission, validation, and autofill as a first-class form control.

@customElement('ds-input')
export class DsInput extends LitElement {
  static formAssociated = true;          // opt into form participation
  private _internals = this.attachInternals();

  @property() value = '';
  @property() name = '';
  @property({ type: Boolean }) required = false;

  private _onInput(e: Event) {
    this.value = (e.target as HTMLInputElement).value;
    this._internals.setFormValue(this.value);          // submits with the form
    if (this.required && !this.value) {
      this._internals.setValidity({ valueMissing: true }, 'This field is required');
    } else {
      this._internals.setValidity({});                  // clears the error
    }
  }

  render() {
    return html`<input .value=${this.value} @input=${this._onInput} />`;
  }
}

With formAssociated = true and setFormValue, the component now appears in FormData and triggers native constraint validation. This pattern is supported across modern Chromium, Firefox, and Safari, so for greenfield apps it is the recommended path rather than reinventing validation by hand.

When NOT to Use Web Components

If your entire organization uses a single framework like React and has no plans to change, building with this approach adds an abstraction layer without clear benefit. React’s component model with JSX is more ergonomic than Lit for React-only teams, and you lose conveniences like typed props and ecosystem-wide devtools. Additionally, while Lit’s SSR story has improved significantly, declarative Shadow DOM still requires extra infrastructure compared to framework-native SSR, and hydration mismatches can be tricky to debug.

Form integration, despite ElementInternals, still surprises teams that assumed everything would “just work” like a plain input. There are also smaller frictions: passing rich objects as attributes requires property binding rather than markup, global CSS resets do not reach into shadow roots, and some older testing tools need configuration to traverse shadow boundaries. Weigh these costs honestly. A component library that will only ever be consumed by one React app is usually better written in React.

Web development tools and browsers
Evaluating framework-agnostic component strategies

Distribution, Versioning, and Consuming the Library

Shipping the components is its own discipline. Publish to npm as a side-effect-free ES module so that bundlers can tree-shake unused components, and mark "sideEffects": false in your package.json with care — the @customElement decorator registers globally, which is a side effect, so guard registration to avoid double-definition errors. A robust pattern is to wrap registration in a check: if (!customElements.get('ds-button')) customElements.define('ds-button', DsButton).

For consumers, generate a custom-elements manifest so editors offer autocomplete and React or Vue can type the elements. In React 19, custom elements finally pass through unknown props as attributes and dispatch custom events correctly, which removes most of the historical wrapper boilerplate. For Angular, add CUSTOM_ELEMENTS_SCHEMA to the module; for Vue, configure isCustomElement in the compiler options. Ship a versioned, documented manifest and your library becomes a dependency teams can adopt without reading your source.

Key Takeaways

A design system built on web standards enables truly reusable UI components that work across any web technology. The 2026 ecosystem offers mature tooling, excellent performance, and broad browser support. Furthermore, building on standards protects your investment from framework churn, since the platform itself is your compatibility layer.

Key Takeaways

  • Start with a solid foundation and build incrementally based on your requirements
  • Render native interactive elements so accessibility and keyboard support come for free
  • Expose theming through CSS custom properties and ::part(), never internal class names
  • Use ElementInternals for any component that needs to participate in forms
  • Document architectural decisions and ship a custom-elements manifest for consumers

Start with 3-5 foundational components (button, input, card, modal, alert) and expand based on team needs. For more details, see the Lit documentation and Open Web Components recommendations. Our guides on Tailwind CSS 4 features and Astro 5 for static sites offer complementary frontend approaches.

In conclusion, the Web Components Lit framework is an essential tool for modern software development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, iterate on your implementation, and continuously measure results to ensure you are getting the most value from these approaches.

← Back to all articles