# Share one stylesheet with ?css-sheet

> Import a CSS file with the css-sheet query and every component that imports it adopts the same hot-swapping 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

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

```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.

```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://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.

```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](/concepts/css-delivery/) explains why that indirection exists.

## Add the type

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

```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

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.](/_astro/indicator-cyan.light.Boi-XPPG_Z10TsE4.webp)![The indicator pulsing cyan after a shared stylesheet edit, with the count unchanged.](/_astro/indicator-cyan.dark.DmGiwpNy_Z1S8s88.webp)

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

Generated stylesheets

A framework that generates CSS from your source swaps in the same way. When you add a class to a component, the framework regenerates the `.css` module, and the sheet picks the new rules up in place. Tailwind and `unoSheet()` both work this way.

## 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`.

```css
@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.

```ts
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](https://github.com/oddcelot/vite-plugin-lit/tree/main/examples/tailwind) runs this setup.

## 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.

```ts
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.

```ts
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:

```ts
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](https://github.com/oddcelot/vite-plugin-lit/tree/main/examples/css) runs this setup.

## 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(…)`.

```css
@property --chip-bg {
  syntax: '<color>';
  inherits: false;
  initial-value: var(--brand);
}
```

```ts
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

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](/reference/compatibility/) has the browser and runtime floor.

## Related

[Options](/reference/options/)cssSheetBuild, its values, and its environment variable.

[Wire a shared sheet by hand with urlSheet()](/guides/stylesheets/url-sheet/)The same sheet when you need control over the fetch.
