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.
Import it
Section titled “Import it”Start with an ordinary stylesheet. Nothing in it is special.
.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.
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://vite-plugin-lit.oddsquad.dev/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.
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.
Add the type
Section titled “Add the type”TypeScript needs to know the import resolves to a CSSStyleSheet. Add the
package’s ambient types to 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.
Edit it and watch
Section titled “Edit it and watch”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 renders: badge on each component is the proof. It does not move.
Share Tailwind utilities
Section titled “Share Tailwind utilities”Tailwind v4 needs no helper. Its Vite plugin fills in a real .css file, so
you import that file with ?css-sheet like any other.
Leave the preflight reset out. It is global, and adopted into a shadow root
it would repeat in every component. Import Tailwind’s theme and utilities
layers instead of all of tailwindcss.
@layer theme, utilities;@import 'tailwindcss/theme.css' layer(theme);@import 'tailwindcss/utilities.css' layer(utilities);Add @tailwindcss/vite beside litPlugin(), then adopt the file in each
component.
import tw from './tailwind.css?css-sheet';
static styles = [tw];Add a class to a component and Tailwind regenerates the sheet, which swaps
in place for every adopter. Shadows, rings, transforms and gradients work
too. Tailwind builds them on properties it registers with @property, which
browsers ignore inside a shadow root, so the plugin registers those rules on
the document. A build only includes that code for a sheet whose compiled css
has a @property rule. The
Tailwind example
runs this setup.
Share UnoCSS utilities
Section titled “Share UnoCSS utilities”unoSheet() writes the UnoCSS utilities your source uses into a real .css
file, so every component can adopt them as one shared sheet. UnoCSS’s own
plugin serves virtual:uno.css, which has no file behind it, so ?css-sheet
cannot wrap it directly.
Add unoSheet() before litPlugin() and point it at the file to write.
import {litPlugin} from '@oddsquad/vite-plugin-lit';import {unoSheet} from '@oddsquad/vite-plugin-lit/uno';import {presetWind3} from 'unocss';
export default defineConfig({ plugins: [ unoSheet({ config: {presets: [presetWind3()]}, outFile: 'src/uno.css', }), litPlugin(), ],});Import the file with ?css-sheet, like any other stylesheet.
import uno from './uno.css?css-sheet';
static styles = [uno];It scans src/ on start, so the first dev load and vite build have every
class. In dev, a class you add to a component rewrites the file, and every
adopter restyles in place. Gitignore outFile, since it is regenerated on
every start.
The sheet carries utilities only. Preflights are left out by default, because
a reset would repeat inside every shadow root. Wind4 keeps its theme variables
(--spacing, --colors-*) and @property rules in preflights, so with
presetWind4() pass preflights: true and drop the reset at the preset:
unoSheet({ config: {presets: [presetWind4({preflights: {reset: false}})]}, outFile: 'src/uno.css', preflights: true,}),Install unocss yourself. It is an
optional peer of the plugin. The
CSS example
runs this setup.
Choose the build output
Section titled “Choose the build output”Dev always builds the sheet from the file’s processed text, with the HMR swap wired up. The build picks between a fetch and inlined text based on what you are building.
| Where | ?css-sheet compiles to |
|---|---|
| dev server | replaceSync() over the ?inline text, swapped on each edit |
vite build (app) |
fetch over the emitted content-hashed .css asset |
vite build + build.lib |
new CSSStyleSheet() + replaceSync() over inlined css text |
| a server render (SSR) | the css text, which Lit’s server renderer writes out |
On the server, where Node has no CSSStyleSheet, the sheet is a stand-in that
carries the css text. Lit’s server renderer writes it into the component’s
declarative shadow root, so the styles arrive with the HTML, in dev and in a
vite build --ssr alike. Once the component hydrates, it adopts the real sheet
in place of the server’s <style>, so edits to the css still hot-swap it.
cssSheetBuild overrides the choice for browser builds; a server build always
inlines.
| 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(…).
@property --chip-bg { syntax: '<color>'; inherits: false; initial-value: var(--brand);}import {defineConfig} from 'vite';import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({ plugins: [litPlugin({cssSheetBuild: 'inline-raw'})],});Use it in a library build
Section titled “Use it in a library build”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.