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.
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.
ExpandLit, Hybrids, Stencil, Haunted, and framework export approaches compared
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:
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:
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:
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:
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:
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: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:
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
- Lit's
htmltagged template literal updates only the expression slots on re-render -- not the full template. The structure is parsed once. - Hybrids is a functional, class-free alternative. No
this, no constructor, just a plain object with arenderfunction. - 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.
- Haunted is Lit plus React hooks.
useStateanduseEffectwork exactly as they do in React. - Svelte, Vue, and Preact each export existing components as web components with minimal wrapper code -- no rewrite required.
- Web component libraries do not prescribe a build pipeline. Bundling is an application concern.
Further Reading and Watching
- Creating Web Components -- With Special Guest Dave Rupert -- live comparison of vanilla and library approaches to authoring components
- lit.dev documentation -- full reference for Lit: reactive properties, templates, lifecycle, events, and context
Keep reading