Web Component Libraries

Lit, Hybrids, Stencil, and Haunted compared -- plus how Svelte, Vue, and Preact can export an existing component as a web component without a rewrite.

June 12, 20267 min read2 / 3

Once the vanilla authoring model made its frustrations clear, I wanted to understand what libraries actually change. They all ship the same output: a registered custom element. The difference is entirely in how you author it.

Lit

Lit is the most widely adopted web component library. It is what Adobe uses for Photoshop on the web and what powers many of the production design systems described in the adoption post.

The central feature is the html tagged template literal.

Lit, Hybrids, Stencil, Haunted, and framework export approaches compared ExpandLit, Hybrids, Stencil, Haunted, and framework export approaches compared

JavaScript
import { LitElement, html, css } from 'lit'; class MyCounter extends LitElement { static properties = { count: { type: Number } }; static styles = css` :host { display: block; } button { padding: 0.5rem 1rem; } `; constructor() { super(); this.count = 0; } render() { return html` <button @click=${() => this.count--}>−</button> <span>${this.count}</span> <button @click=${() => this.count++}>+</button> `; } } customElements.define('my-counter', MyCounter);

The html tag parses the template structure once. On each re-render, Lit only updates the parts of the DOM that contain expressions -- the ${...} slots -- not the surrounding structure. This is efficient without requiring manual memoization.

The static properties declaration has two modes:

JavaScript
static properties = { count: { type: Number }, // reflectable prop -- can be set from outside step: { state: true }, // private state -- only the component touches this };

state: true is private reactive state. The component re-renders when it changes, but nothing outside the shadow root can read or set it. Without that flag, the property is a reflectable prop -- it still drives re-renders, but it is also accessible from anywhere on the page:

JavaScript
document.querySelector('my-counter').count = 10;

That line works. You cannot do the equivalent with a React component -- there is no DOM handle that exposes internal state directly. This is the distinction between local state and props, expressed in a single static properties declaration.

The lifecycle extends the native callbacks with cleaner hooks: willUpdate (before render, useful for deriving values), firstUpdated (after the first render, like componentDidMount), and updated (after every render). The loop is: property change → willUpdate → render → updated → wait.

Beyond the library itself, Lit has attracted the largest community of any web component tooling. Context APIs, reactive state patterns, component catalogues -- that work happens at the community level first, and the Lit ecosystem is where most of it lands.

A library is only as strong as what the ecosystem builds on top of it. If a problem you have is not solved in vanilla web components, there is a good chance someone in the Lit community has addressed it.

Hybrids

Hybrids takes a functional approach. No class, no this, no constructor. You define a plain object:

JavaScript
import { define, html } from 'hybrids'; const MyCounter = { count: 0, render: ({ count }) => html` <button onclick="${(host) => host.count--}">−</button> <span>${count}</span> <button onclick="${(host) => host.count++}">+</button> `, }; define('my-counter', MyCounter);

If class-based components feel syntactically noisy, Hybrids is immediately lighter. Around 4KB. Suited for components with straightforward property-driven rendering and teams that prefer functional patterns.

Stencil

Stencil is what powers Ionic's component library. It uses TypeScript decorators, giving it an Angular-adjacent feel:

TSX
import { Component, Prop, State, h } from '@stencil/core'; @Component({ tag: 'my-counter', shadow: true }) export class MyCounter { @Prop() initialCount = 0; @State() count = 0; componentWillLoad() { this.count = this.initialCount; } render() { return ( <div> <button onClick={() => this.count--}></button> <span>{this.count}</span> <button onClick={() => this.count++}>+</button> </div> ); } }

JSX, TypeScript, decorators -- this is the most "framework-flavored" of the options. The notable capability: Stencil has built-in tooling to compile a web component into framework-specific wrapper packages for React, Vue, and Angular. One component definition, multiple distribution targets.

Haunted

Haunted is Lit with React hooks. useState, useEffect, useReducer -- the full hooks API, wired to Lit's rendering engine:

JavaScript
import { component, html, useState } from 'haunted'; function MyCounter() { const [count, setCount] = useState(0); return html` <button @click=${() => setCount(count - 1)}>−</button> <span>${count}</span> <button @click=${() => setCount(count + 1)}>+</button> `; } customElements.define('my-counter', component(MyCounter));

If you are comfortable with the hooks model but do not want React as a dependency, Haunted bridges that gap. It is a thin layer -- the hooks are familiar, the output is a standard web component.

Exporting From Existing Frameworks

You do not have to switch stacks to offer web components. Most modern frameworks can compile an existing component to a custom element.

Svelte is the easiest:

SVELTE
<svelte:options customElement="my-counter" /> <script> let count = 0; </script> <button on:click={() => count--}>−</button> <span>{count}</span> <button on:click={() => count++}>+</button>

One line at the top. Svelte's compiler handles the rest. No changes to the component logic.

Vue wraps an existing component with defineCustomElement:

JavaScript
import { defineCustomElement } from 'vue'; import MyCounter from './MyCounter.vue'; customElements.define('my-counter', defineCustomElement(MyCounter));

MyCounter.vue stays unchanged. You add an entry point that registers it as a custom element. This is the "ship to a non-Vue context" pattern -- if a Vue component library needs to reach a WordPress blog or a legacy CMS, this lets you publish a custom element build alongside the Vue build without touching the component itself.

Preact follows the same pattern via createCustomElement. One difference from Vue: Preact's web component output uses htm/preact (the tagged template literal approach) rather than JSX. You pass the props list explicitly so the element knows which attributes to observe.

React

React is the one notable holdout. The attribute-vs-property handling in React 18 and earlier created friction when consuming web components. React 19 adds first-class custom element support that resolves this -- handling the attribute-vs-property distinction according to the HTML spec.

Once React 19 is in your team's stack, web components become interoperable in both directions: consuming custom elements inside React, and exporting React components as custom elements.

Lazy Loading and Bundling

Web component imports build a dependency tree naturally. If a component is not imported, it does not load. In multi-page architectures, each page loads only what it uses.

In a single-page application, that requires intentional code splitting at the bundler level. The choice of Lit, Stencil, or any other library does not dictate your build tool. Bundling, tree-shaking, and code-splitting are application-layer concerns -- the component does not prescribe the pipeline.

Web components can also reference other web components. Component A importing B importing C is fine -- the native import system resolves the chain. In production, each unresolved import is a separate network request.

That is the real argument for bundling: not an authoring concern, but a deployment one.

Picking a library is mostly picking a mental model. Once you have components that produce standard custom elements, they slot into any HTML page the same way -- regardless of the tool that authored them. The next step is using one in a real project: fetching data, managing loading state, and updating components reactively with Lit.

The Essentials

  1. Lit's html tagged template literal updates only the expression slots on re-render -- not the full template. The structure is parsed once.
  2. Hybrids is a functional, class-free alternative. No this, no constructor, just a plain object with a render function.
  3. Stencil uses JSX and TypeScript decorators. Its main differentiator is built-in output targets -- one component definition can compile to web component, React wrapper, Vue wrapper, and Angular wrapper.
  4. Haunted is Lit plus React hooks. useState and useEffect work exactly as they do in React.
  5. Svelte, Vue, and Preact each export existing components as web components with minimal wrapper code -- no rewrite required.
  6. Web component libraries do not prescribe a build pipeline. Bundling is an application concern.

Further Reading and Watching