Skip to content

Share one stylesheet with ?css-sheet

Import a .css file with ?css-sheet and every component that imports it shares one CSSStyleSheet. Edit the file and every adopter restyles in place, with no re-render.

Start with an ordinary stylesheet. Nothing in it is special.

src/hmr-vsheet.css
.chip {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 700;
background: rgb(59, 130, 246);
color: rgb(255, 255, 255);
}

Import it with ?css-sheet and hand the result straight to static styles. There is no helper to import and no import.meta.hot to write.

src/hmr-vsheet-a.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import sheet from './hmr-vsheet.css?css-sheet';
/**
* Adopts a shared hot-swapping `CSSStyleSheet` via the plugin's `?css-sheet`
* query — the whole `urlSheet()` + `import.meta.hot.accept` wiring is
* generated by the plugin, so this module is just a bare import. Editing
* `hmr-vsheet.css` restyles this and `hmr-vsheet-b` in place, no re-render.
*
* Tradeoffs — same fetched-shared-sheet profile as `hmr-utility-sheet`
* (recommended for a shared utility layer: one sheet, cacheable `.css` asset,
* in-place HMR; brief FOUC/CLS on first load), but with zero boilerplate. ❌
* Only works through this plugin — `urlSheet()` is the portable form. See
* https://oddcelot.github.io/vite-plugin-lit/guides/stylesheets/
* (benchmarked in bench/).
*/
@customElement('hmr-vsheet-a')
export class HmrVsheetA extends LitElement {
static override styles = [sheet];
private renders = 0;
override render() {
return html`<span class="chip" id="chip">A</span>
<span 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}`;
}
}
}

A second component importing the same specifier gets the same object, not a copy. That is the whole point: one parse, one sheet, adopted by both shadow roots.

src/hmr-vsheet-b.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import sheet from './hmr-vsheet.css?css-sheet';
/**
* A second adopter of the same `?css-sheet` import. Both modules import the
* same virtual module, so they share one `CSSStyleSheet` instance — a CSS
* edit updates both without re-rendering either.
*
* Approach benefits/problems: see `hmr-vsheet-a` / `hmr-utility-sheet`.
*/
@customElement('hmr-vsheet-b')
export class HmrVsheetB extends LitElement {
static override styles = [sheet];
private renders = 0;
override render() {
return html`<span class="chip" id="chip">B</span>
<span 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 plugin generates the wiring the sheet needs in a virtual module, which is why your code stays a bare import. CSS delivery explains why that indirection exists.

TypeScript needs to know the import resolves to a CSSStyleSheet. Add the package’s ambient types to tsconfig.json.

tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"types": ["vite/client", "@oddsquad/vite-plugin-lit/client"]
},
"include": ["src", "vite.config.ts"]
}

A single-file alternative is /// <reference types="@oddsquad/vite-plugin-lit/client" /> at the top of a module.

Change a colour in hmr-vsheet.css and save. Both chips restyle, neither component re-renders, and the on-page indicator gives the calmer cyan pulse reserved for updates that do not re-render anything.

The indicator pulsing cyan after a shared stylesheet edit, with the count unchanged.The indicator pulsing cyan after a shared stylesheet edit, with the count unchanged.

The renders: badge on each component is the proof. It does not move.

Dev is always the fetch-backed HMR form. The build picks between a fetch and inlined text based on what you are building.

Where ?css-sheet compiles to
dev server fetch over the dev-served file, with the HMR swap wired up
vite build (app) fetch over the emitted content-hashed .css asset
vite build + build.lib new CSSStyleSheet() + replaceSync() over inlined css text

cssSheetBuild overrides that choice.

Value Build output
'auto' default — 'inline' when build.lib is set, 'url' otherwise
'url' always fetch-backed over an emitted .css asset
'inline' css text in the JS chunk, processed by the css pipeline (?inline)
'inline-raw' css text in the JS chunk verbatim, skipping the pipeline (?raw)

'inline' runs the same css pipeline the 'url' asset goes through, so the two agree. Reach for 'inline-raw' when the configured transformer rejects css that browsers accept. Lightning CSS, for one, errors on @property with an initial-value: var(…).

src/theme.css
@property --chip-bg {
syntax: '<color>';
inherits: false;
initial-value: var(--brand);
}
vite.config.ts
import {defineConfig} from 'vite';
import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({
plugins: [litPlugin({cssSheetBuild: 'inline-raw'})],
});

A library build is the one case where the default flips. Consumers bundle your JS only, so the .css asset never reaches their build and the runtime fetch would 404 into a silently empty sheet. Under build.lib the plugin therefore embeds the css text in the JS chunk and emits no .css asset.

Both inline flavours keep what the fetch-backed form gives you: one sheet object per css file, shared by every importer, and no flash of unstyled content, because there is no fetch to wait on. They construct the sheet at module top level, so importing the built module needs constructable-stylesheet support. Compatibility has the browser and runtime floor.