Wire a shared sheet by hand with urlSheet()
Build the shared, hot-swapping sheet yourself when you need a custom fetch or
code that runs without this plugin. If you do not, use
?css-sheet.
Create the sheet module
Section titled “Create the sheet module”Start with the stylesheet many components will adopt. Imported with ?url, it
stays a standalone, pipeline-processed .css file in the build output.
.flex { display: flex;}.flex-col { flex-direction: column;}.items-center { align-items: center;}.gap-2 { gap: 0.5rem;}.px-4 { padding-left: 1rem; padding-right: 1rem;}.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem;}.rounded { border-radius: 0.375rem;}.bg-blue { background: #3b82f6;}.bg-green { background: #22c55e;}.bg-red { background: #ef4444;}
.text-white { color: #fff;}.text-xs { font-size: 0.75rem;}.text-sm { font-size: 0.875rem;}.font-bold { font-weight: 700;}.font-mono { font-family: var(--font-mono, ui-monospace, monospace);}.border-none { border: none;}.cursor-pointer { cursor: pointer;}urlSheet() turns that URL into one constructed CSSStyleSheet and the
callback that swaps it. It fetches the file into the sheet, then replaceSync()s
the new text on every edit, so adopters restyle without a re-render and without
a reload.
import {urlSheet} from '@oddsquad/vite-plugin-lit/css.js';import sheetUrl from './hmr-utility-sheet.css?url';
/** * A single `CSSStyleSheet` adopted by multiple components — simulates a * utility-first framework output (Tailwind, UnoCSS) shared across the app. * When the utility classes change, the sheet hot-swaps in place: every * adopter updates without re-rendering a single component, and without a * full-page reload. * * `urlSheet()` (from the plugin's CSS helpers) keeps the stylesheet as a * *standalone, pipeline-processed `.css` asset* in the build output — unlike * the `?inline`/`?raw` shared-sheet demos, which inline the CSS into the JS * bundle — and fetches it into the constructed sheet at runtime. The cost is * a brief flash of unstyled content on initial load while that fetch is in * flight. * * The `import.meta.hot.accept` call has to live here with the same literal * specifier as the import: Vite resolves accepted HMR deps by static * analysis, so the helper can't register it for us. * * Tradeoffs — fetched shared sheet, the recommended shape for a large utility * sheet. ✅ One parsed sheet across every adopter (fewest objects/nodes, * fastest mount), stays an independently cacheable `.css` asset out of the JS * chunks, and hot-swaps in place. ❌ The runtime `fetch()` means a brief FOUC * on first load — in the benchmark that surfaced as a layout shift (CLS). * Mitigate with a `<link rel="preload" as="style">`, or use the inline shared * sheet (`hmr-shared-sheet`) when first-paint stability matters more than an * external asset. `?css-sheet` is the zero-boilerplate form of this (see * `hmr-vsheet-a`) — * https://oddcelot.github.io/vite-plugin-lit/guides/stylesheets/ * (benchmarked in bench/). */const {sheet, onHotUpdate} = urlSheet(sheetUrl);
export default sheet;
if (import.meta.hot) { import.meta.hot.accept('./hmr-utility-sheet.css?url', onHotUpdate);}The sheet is empty until the first fetch resolves, so expect FOUC: a brief
flash of unstyled content while the stylesheet is still loading. A
<link rel="preload" as="style"> in the document head removes most of it.
Adopt it in components
Section titled “Adopt it in components”Import the module and put the sheet in static styles. Every importer gets
the same object, so an edit to the CSS reaches all of them at once.
import {LitElement, html} from 'lit';import {customElement, property} from 'lit/decorators.js';import utilitySheet from './hmr-utility-sheet.js';
/** * Button styled via utility classes from a shared stylesheet — simulates a * Tailwind/UnoCSS workflow. All utility consumers update in place when the * generated CSS changes (e.g. a new theme color), without re-rendering. * * Approach benefits/problems: see `hmr-utility-sheet` (fetched shared sheet). */
@customElement('hmr-utility-btn')export class HmrUtilityBtn extends LitElement { static override styles = [utilitySheet];
@property() variant: 'blue' | 'red' = 'blue';
private renders = 0;
override render() { const bgClass = this.variant === 'blue' ? 'bg-blue' : 'bg-red'; return html` <button class="${bgClass} text-white font-bold py-2 px-4 rounded border-none cursor-pointer" > <slot></slot> </button> <span class="font-mono text-xs" id="badge">renders: 0</span> `; }
override updated() { this.renders++; this.setAttribute('data-renders', String(this.renders)); const badge = this.renderRoot.querySelector('#badge'); if (badge !== null) { badge.textContent = `renders: ${this.renders}`; } }}The sheet is constructed at module top level, which needs constructable-stylesheet support; see Compatibility for the floor.
Keep the accept line where Vite can see it
Section titled “Keep the accept line where Vite can see it”The import.meta.hot.accept call has to sit in the module that imports the
CSS, spelled with the same literal specifier. Vite resolves accepted HMR
dependencies by static analysis, so the string must appear in your source.
This constraint is the entire reason ?css-sheet exists. It generates the
same two lines in a virtual module so your code stays a bare import.