All articles

Frontend

Lit Components in a React App: A Practical Setup Guide

A walkthrough of setting up Lit, writing a custom element, and integrating it into a React app — with a working demo component and the gotchas that cost the most time.

JUL 26, 2026 7 min readBy Aryan Ranjan
AR / Frontend

If you build component libraries for a living, you've probably had the same conversation more than once. Someone on the team wants React. Someone else wants native browser primitives so the same component works in a Vue app, a static landing page, and a micro-frontend. You want a single source of truth that ships as a custom element, hydrates with zero framework runtime, and behaves like an HTML tag in every framework — including ones that don't exist yet.

That's the pitch for Lit. After spending the last year building and shipping a Lit-based component library inside a large React codebase at work, I want to walk you through the setup I wish someone had handed me on day one. We'll go from npm init all the way to a working <like-button> rendered inside a React app, with full event handling.

Why Lit (in a React codebase)?

Lit is a tiny library — about 5 kB gzipped — that adds three things on top of the platform:

  1. A LitElement base class with reactive properties.
  2. A tagged-template-literal HTML function for type-safe template strings.
  3. Scoped CSS via css that the browser applies through Shadow DOM.

The killer feature for cross-framework work is that a Lit element is a real custom element. React ≥ 19 treats unknown tags as DOM elements and passes props as attributes or properties. React ≤ 18 mostly works too, with the well-known caveat that event names have to be lowercased and that you need @lit/react for typed event handlers.

What you get back:

  • One bundle that runs in React, Angular, Vue, or a plain <script> tag.
  • Real encapsulation (Shadow DOM), so styles don't leak in or out.
  • Reactive props, lifecycle hooks, and declarative templates — basically the ergonomic bits of React without the framework runtime.

If you're evaluating Lit vs. Web Components vs. shipping a React-only library, the deciding factor is almost always: who else is going to consume this? If the answer is "more than one framework" — Lit wins.

The project setup

I'm going to use Vite for the build. It's fast, it handles TypeScript out of the box, and it tree-shakes Lit aggressively because Lit is ESM-first and side-effect-free at the module level.

npm create vite@latest lit-react-demo -- --template lit-ts
cd lit-react-demo
npm install

For a real component library you'll want a few extras. Add them now — the configurations we'll write assume they exist:

npm install lit
npm install -D @lit/react react react-dom @types/react @types/react-dom

Notice lit is a regular dependencies entry and react is a devDependency. In a library context you'll flip that: lit goes into peerDependencies and the consumer provides React. We'll come back to this.

tsconfig.json

You need experimentalDecorators and useDefineForClassFields: false for Lit 3's reactive properties to behave correctly:

{
  "compilerOptions": {
    "target": "ES2021",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2021", "DOM", "DOM.Iterable"],
    "strict": true,
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    "skipLibCheck": true,
    "isolatedModules": true
  },
  "include": ["src"]
}

If you skip these two flags, your @property decorators will silently do nothing and you'll spend an afternoon wondering why this.count never updates.

Anatomy of a Lit component

Let's look at the smallest useful Lit element and unpack every line:

import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement("like-button")
export class LikeButton extends LitElement {
  // Reactive property — reflected to attribute, observed by the framework.
  @property({ type: Number }) count = 0;
  @property({ type: Boolean, reflect: true }) liked = false;

  // Scoped styles. Only applies inside this element's shadow root.
  static styles = css`
    :host {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      font-family: system-ui, sans-serif;
    }
    button {
      all: unset;
      cursor: pointer;
      padding: 0.4rem 0.8rem;
      border-radius: 999px;
      border: 1px solid currentColor;
      transition: transform 80ms ease;
    }
    button:active { transform: scale(0.96); }
    :host([liked]) button { background: #ff3366; color: white; }
    .heart { font-size: 1.1rem; }
  `;

  private toggle() {
    this.liked = !this.liked;
    this.count += this.liked ? 1 : -1;

    // Fire a standard DOM event the host framework can listen to.
    this.dispatchEvent(new CustomEvent("like-change", {
      detail: { liked: this.liked, count: this.count },
      bubbles: true,
      composed: true,
    }));
  }

  render() {
    return html`
      <button @click=${this.toggle} aria-pressed=${this.liked}>
        <span class="heart">${this.liked ? "♥" : "♡"}</span>
        <span>${this.count}</span>
      </button>
    `;
  }
}

Walking through it:

  • @customElement("like-button") registers <like-button> as a custom element. The decorator is sugar for customElements.define("like-button", LikeButton).
  • @property() turns a class field into a reactive property. Lit tracks reads inside render() and re-renders only when observed values change. reflect: true mirrors the JS property to an HTML attribute, which is useful for CSS (see :host([liked]) above) and for tooling that inspects the DOM.
  • static styles = css\...`declares scoped styles. Lit adopts them into the element's Shadow DOM, so they cannot leak out and parent styles cannot leak in.:host` is the shadow tree's root element.
  • render() returns a template. The html tag is a tagged template literal that Lit compiles to a DOM update function. Inline event listeners use @click=${...} (Lit-style) instead of React-style onClick.
  • this.dispatchEvent(new CustomEvent(...)) is how Lit components talk to the outside world. Always set composed: true if you want the event to cross shadow root boundaries — otherwise React's synthetic event system won't see it.

That's it. No useState, no useEffect, no provider. The element is just a class.

Integrating into React

There are two ways to use a Lit component in React, and the one you pick depends on which React you're on.

React 19+: it just works

React 19 finally added first-class support for custom elements. Unknown tags are treated as DOM elements, props are passed as attributes (or set as properties if they exist on the element), and event listeners are auto-lowercased. So:

import "./like-button"; // registers the custom element on import

export function PostFooter({ initialCount }: { initialCount: number }) {
  return (
    <like-button
      count={initialCount}
      liked={false}
      onLike-change={(e: CustomEvent) => {
        console.log("User toggled like:", e.detail);
      }}
    />
  );
}

Note onLike-change — kebab-case event names are converted to camelCase React props by React 19. The detail arrives on e.detail as a normal CustomEvent.

If you're on React 18 or older, that lowercase-only event handling bites you. You have two options: use useRef + addEventListener directly, or use @lit/react.

React 18 and below: @lit/react

@lit/react wraps a Lit element into a real React component. You get proper typings for both props and events, and you don't have to worry about the kebab/camel case dance:

import { createComponent } from "@lit/react";
import React from "react";
import { LikeButton } from "./like-button";

export const LikeButtonReact = createComponent({
  react: React,
  tagName: "like-button",
  elementClass: LikeButton,
  events: {
    // Map event names to React-style camelCase handlers.
    onLikeChange: "like-change",
  },
});

Then in your app:

export function PostFooter({ initialCount }: { initialCount: number }) {
  return (
    <LikeButtonReact
      count={initialCount}
      liked={false}
      onLikeChange={(e) => console.log(e.detail)}
    />
  );
}

Both approaches render the exact same <like-button> element under the hood. The difference is just who wires up the props and events.

The end-to-end demo

Let's stitch it all together. Drop the Lit element into src/like-button.ts, the React wrapper into src/LikeButtonReact.tsx, and render it from src/App.tsx:

// src/App.tsx
import { useState } from "react";
import { LikeButtonReact } from "./LikeButtonReact";

export default function App() {
  const [total, setTotal] = useState(42);

  return (
    <main style={{ fontFamily: "system-ui", padding: "2rem" }}>
      <h1>Lit + React demo</h1>
      <p>Total likes across the page: {total}</p>

      <LikeButtonReact
        count={7}
        liked={false}
        onLikeChange={(e) => {
          const { liked } = e.detail;
          setTotal((t) => t + (liked ? 1 : -1));
        }}
      />
    </main>
  );
}

Run npm run dev, open the page, click the heart. Three things to notice:

  1. The button styles don't break even though I gave the parent no global CSS — that's Shadow DOM doing its job.
  2. The total counter in the parent updates when the child dispatches its event, even though the child is in a shadow tree. composed: true is what made that work.
  3. If you inspect the DOM, you'll see the element has a liked attribute that flips between "" and null. That's reflect: true doing its job.

Patterns that bite if you ignore them

A few things that have cost me time and will probably cost you some too.

Don't render the Lit component inside a key-based remount loop. If you have <LikeButtonReact key={post.id} ... /> and the key changes on every re-render (it shouldn't, but it happens), the element unmounts and remounts, losing any internal state. Treat Lit elements like you treat form inputs — keep them stable across renders.

Forms need formAssociated. If your component participates in a real <form>, set static formAssociated = true and implement formAssociatedCallback, formResetCallback, and formDisabledCallback. Otherwise the form will not see your values on submit.

Server-side rendering is awkward. Lit components render into a shadow root, which React's SSR does not hydrate. For the SEO-critical surfaces in the project I'm working on we render a static HTML shell on the server and let Lit upgrade the elements on the client. For content that doesn't need to be crawlable, just let it render on the client only — gate it behind a useEffect mount check or use next/dynamic with ssr: false.

TypeScript and decorators. If you upgrade to TypeScript 5+, you'll need experimentalDecorators: true and standard decorators will eventually be the path forward. Lit 3 supports both with a small tsconfig tweak (useDefineForClassFields: false); Lit 4 will prefer the standard form. Plan for that migration before you commit to a big library.

Where this fits in a real codebase

At work, the Lit component library is its own package — @pw/revenue-fe-lib — with a clean public surface: one entrypoint per component. The host React app imports each one by subpath, so bundlers can tree-shake aggressively. Every component follows a tiny triad: index.ts for the class and defineComponent helper, styles.ts for the scoped CSS, and types.ts for the public prop interface. CI runs tsc --noEmit, eslint, and a size-limit budget per subpath so we catch bundle regressions before they ship.

The payoff is real: the same <batch-card> renders on the marketing site (Next.js, React), inside an Angular admin panel, and inside a micro-frontend that has no framework at all. We write the component once, we test it once, and every consumer gets the same DOM. No adapter layer, no "React-only" branch, no support ticket six months from now when someone wants to add it to a Vue app.

Try it yourself

If you want to play with the exact setup from this post, the Lit docs have a working starter, and create-lite-element is a good scaffold for a library. Pair that with @lit/react for your React host and you'll have the same architecture I just described — minus the six months of footguns.

That's the whole loop. Set up once, write components in plain TypeScript, and use them from anywhere. If you've been waiting for a sign to try Lit in your React project, this is it.