A Vite plugin for Lit projects with true HMR, CSS delivery helpers for shadow roots, and a DevTools timeline.
video.mp4
Started life in a fork of the lit monorepo as a
proposed @lit-labs/vite-hmr package, and now lives here on its own. Lit is
kept checked out as a read-only submodule for reference and opt-in
canary testing against lit main.
Each demo opens on StackBlitz and runs the latest release of the plugin in your browser. The Demos page embeds them, so you can try one without leaving the docs.
| Demo | What to try | |
|---|---|---|
| Playground | HMR and the Vite DevTools dock: click the counter, edit src/hmr-counter.ts, and the count stays. |
|
| Standalone mode | The same page as static files, without Vite, next to the DevTools panel on its own port. | |
| Lit flame | One component: turn its colours, stoke it and blow it out, then edit it and watch its state, #private field and colours survive. |
|
| Web Awesome | A component built from a third-party Lit library: edit it, and the library's elements keep their checked boxes and half-typed text. | |
| Fernhouse | A small shop to explore the DevTools on: pick a card, open its slots and parts in Anatomy, then record adding it to the cart. | |
| CSS | One card per way of styling a shadow root, with UnoCSS as one shared sheet: change --brand and every card recolours without re-rendering. |
|
| Tailwind | Tailwind v4 utilities as one shared sheet: add a class to a component and the sheet swaps in place, with shadows and gradients working in shadow roots. | |
| Forms | Form-associated custom elements from one base class, in a plain <form>: submit it empty, reset it, and watch each control's :state() change. |
|
| Lit SSR | Components rendered on the server with @lit-labs/ssr, then hydrated: click the counter, edit src/ssr-counter.ts, and the count stays. |
lit-html decides "is this the same template?" by object identity of the
TemplateStringsArray, not by content. Vite HMR re-executes an edited module,
so a one-character edit rebuilds the component's entire subtree and takes
focus, scroll, input state, and child @state with it.
This plugin fixes that with two cooperating mechanisms:
- Template-strings interning — the
html/svg/mathml/csstags are wrapped in dev so strings arrays are canonicalized by content, and only the edited template rebuilds its part of the DOM. - In-place class patching — the duplicate
customElements.definefrom the re-executed module patches the originally-registered class in place, then restores reactive property values through the new accessors.
// vite.config.ts
import {defineConfig} from 'vite';
import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({
plugins: [litPlugin()],
});The HMR feature only applies to the dev server (apply: 'serve'); production
builds are untouched. The CSS queries (?hmr-url, ?css-sheet) and Lightning
CSS literal processing apply in both dev and build.
The dev-only code (HMR, the source overlay, DevTools) is imported only when
the dev server (or vite preview) starts, so vite build never loads it. For
a build config that depends on even less, use the build-only entry, which
carries just the CSS plugins:
import {litCss} from '@oddsquad/vite-plugin-lit/build';
export default defineConfig(({command}) => ({
plugins: [command === 'serve' ? litPlugin() : litCss()],
}));- HMR that keeps state — what survives an edit, and what falls back to a reload.
- Stylesheets at scale
— one shared
CSSStyleSheetfor thousands of shadow roots, hot-swapped without re-rendering, from a bare?css-sheetimport. - DevTools timeline — a layered event recorder and live component inspector inside Vite DevTools.
- Source overlay — click any element in the page to open its source in your editor.
- Agent access — the same component tree and timeline as MCP tools, for coding agents.
- Ecosystem support — signals, context, tasks, and the virtualizer across a patch.
Reference: options · environment variables · import queries · runtime API · CLI · limitations · compatibility · benchmarks · troubleshooting
The DevTools panel's data is also exposed as MCP tools — lit_list-components,
lit_component-details, lit_component-docs, lit_update-summary,
lit_recent-events, lit_range-summary, lit_get-meta, lit_hmr-history,
lit_hmr-incompatibilities, lit_set-recording, lit_set-prop — so an agent
can read the live component tree and timeline instead of guessing from source,
and try a property value on a live element.
These answer only while a Vite dev server with timeline on is running. There
is no stored data and nothing to go stale: with the server down an agent gets
a connection error, not an empty answer.
Point an MCP client at it either way:
# Auto-discovers every running dev server on this machine.
claude mcp add lit-devtools -- npx -y @oddsquad/vite-plugin-lit mcp
# Or dial one server directly, if you'd rather pin the port
# (`/__lit/__mcp` when the server runs without Vite DevTools).
claude mcp add lit-devtools -- npx -y mcp-remote http://localhost:5179/__devtools/__mcpThe equivalent claude_desktop_config.json / mcp.json entry:
{
"mcpServers": {
"lit-devtools": {
"command": "npx",
"args": ["-y", "@oddsquad/vite-plugin-lit", "mcp"]
}
}
}The two differ in how the server is found, not in what it can do:
... vite-plugin-lit mcpruns devframe's connector over stdio. The plugin publishes each running dev server to devframe's instance registry, and the connector lists them through two gateway tools (devframe_connect_list-instances,devframe_connect_call-tool) — so one entry covers every project you have running, with no port to keep in sync. Being a shared connector, it surfaces every devframe on the machine, not only this plugin's tools.mcp-remote <url>bridges stdio to one fixed URL and depends on no discovery at all. Reach for it if registry discovery is unavailable — the plugin logs a warning saying so at startup, naming the reason.
The CLI also has lit-devtools dev, a standalone devframe server. Started on
its own it shows an empty panel, because it has no page of its own. Add the
script tag it prints at startup to any page, including one that Vite does not
serve and one on another origin, and the page feeds it the live component tree,
inspector and timeline:
<script src="http://localhost:5180/lit-devtools.js"></script>Put it before the scripts that define your components. The server asks for its
one-time code unless you start it with --no-auth: type the code into the
prompt the page shows, open the page once with #devframe_otp=<code> on its
URL, or skip the gate on a loopback host with --no-auth. Pages on a loopback
origin (any port) may connect; for any other origin, pass it to the server with
--allow-origin https://myapp.test:8443 (repeatable). A * in the host
matches any subdomain, e.g. --allow-origin 'https://*.webcontainer-api.io'
for StackBlitz, where every port gets its own origin.
Outside Vite the page has no build-time transforms, so HMR patching and
open-in-editor do not work there; the tree, inspector and timeline do. Source
locations come from the page's sourcemaps when it ships them. A page that Vite serves can instead call connectToDevServer() from
@oddsquad/vite-plugin-lit/connect.js.
Production builds. The script tag also attaches to a minified build with
Lit's production condition, checked by
src/test/e2e/standalone-production_test.ts:
- Works: the component tree, the inspector (properties, controllers and
@lit/taskinstances, even though Lit mangles its private controller set) and Record with the lifecycle layer (performUpdate,willUpdate,update,updated). - Empty: the render layers, because Lit's production build never dispatches
lit-debugevents. - Missing: open-in-editor and HMR (no build-time transform). Source locations come from the build's sourcemaps, when it ships them.
- Blocked: a page whose Content Security Policy does not allow the dev server's
origin in
script-src(for examplescript-src 'self') refuses the tag, so nothing attaches. - Cosmetic: names the minifier renamed show as it left them, so a plain controller's type reads as a short mangled identifier.
To try it from this repo, run pnpm run standalone:demo; see
Playground.
A manually inspectable fixture app (also the source for the e2e fixtures), and self-contained enough to open on StackBlitz straight from the repo URL. Run it locally from the repo root:
pnpm dev # builds the plugin, then serves http://localhost:5179The same playground runs in standalone mode, built to static files and fed to
a lit-devtools dev panel on another port: pnpm run standalone:demo here, or
on StackBlitz
with ?startScript=standalone. StackBlitz installs the published plugin, so
both links run the latest release of the plugin against the playground on main.
Requires Node 26 and pnpm 12 (corepack enable picks up the pinned version).
The published package itself runs on Node 20 or newer.
pnpm install # workspace: root package + playground + docs
pnpm build # tsc + panel assets -> ./lib, ./panel, ./index.js
pnpm exec vp check # format, lint, type check
pnpm test # unit + e2e
pnpm docs:dev # the documentation siteMore in Development and Testing. Issues and pull requests are welcome; see CONTRIBUTING.md.
BSD-3-Clause. See LICENSE.
The DevTools bundle Web Awesome components (MIT) in the panel and Phosphor icons (MIT) in the panel and the in-page overlay.