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.
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:
- A
LitElementbase class with reactive properties. - A tagged-template-literal HTML function for type-safe template strings.
- Scoped CSS via
cssthat 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 forcustomElements.define("like-button", LikeButton).@property()turns a class field into a reactive property. Lit tracks reads insiderender()and re-renders only when observed values change.reflect: truemirrors 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. Thehtmltag is a tagged template literal that Lit compiles to a DOM update function. Inline event listeners use@click=${...}(Lit-style) instead of React-styleonClick.this.dispatchEvent(new CustomEvent(...))is how Lit components talk to the outside world. Always setcomposed: trueif 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:
- The button styles don't break even though I gave the parent no global CSS — that's Shadow DOM doing its job.
- The
totalcounter in the parent updates when the child dispatches its event, even though the child is in a shadow tree.composed: trueis what made that work. - If you inspect the DOM, you'll see the element has a
likedattribute that flips between""andnull. That'sreflect: truedoing 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.