Skip to content

Style a single component from a .css file

Keep a component’s own rules in a .css file next to it, and choose whether the browser fetches it or the bundle carries it. Both re-render that component on edit, which is fine for styles only it uses.

The ?hmr-url query yields a real stylesheet URL: the dev server serves the file, and vite build emits it as a hashed .css asset. Point a <link rel="stylesheet"> inside the shadow root at it.

src/hmr-linked-css.css
#linked-box {
background: rgb(200, 50, 50);
color: #fff;
padding: 1rem;
border-radius: 4px;
&:hover {
background: oklch(62% 0.19 25);
}
}
src/hmr-linked-css.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import cssHref from './hmr-linked-css.css?hmr-url';
/**
* External stylesheet loaded via <link> in shadow root.
*
* The plugin's `?hmr-url` import query yields a real stylesheet URL — the
* dev server serves the CSS file directly, and `vite build` emits it as a
* hashed `.css` asset — unlike `?inline`, which would bake the CSS into the
* JS bundle.
*
* When the .css file changes, Vite's HMR propagates through the query's
* wrapper module to this component module (self-accepting via the Lit HMR
* plugin). Re-execution imports a freshly cache-busted href and the
* component re-renders, making the browser refetch the stylesheet.
*
* Tradeoffs — per-element delivery. ✅ Real cacheable `.css` asset; standard
* `<link>`; no JS to wire. ❌ One `<link>` + `CSSStyleSheet` object per
* instance, and every CSS edit re-renders the whole component. For a sheet
* shared across many components, prefer a shared adopted sheet (`?css-sheet`)
* — see
* https://oddcelot.github.io/vite-plugin-lit/guides/stylesheets/
* (benchmarked in bench/).
*/
@customElement('hmr-linked-css')
export class HmrLinkedCss extends LitElement {
private renders = 0;
override render() {
return html`
<link rel="stylesheet" href="${cssHref}" />
<h2>Linked CSS</h2>
<div id="linked-box">styled via &lt;link&gt;</div>
<span class="badge" 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}`;
}
}
}

On edit the component re-renders with a freshly cache-busted href, so the browser refetches. The same import works inside @import url() in a <style> element, which the hmr-import-css.ts fixture demonstrates.

?raw hands back the file text verbatim; ?inline hands back the text after Vite’s CSS pipeline. Either one feeds unsafeCSS for static styles, so the bytes ride in the JS chunk with no runtime fetch and no flash of unstyled content.

src/hmr-raw-css.css
#raw-box {
background: rgb(150, 100, 30);
color: #fff;
padding: 1rem;
border-radius: 4px;
}
src/hmr-raw-css.ts
import {LitElement, html, unsafeCSS} from 'lit';
import {customElement} from 'lit/decorators.js';
import rawCss from './hmr-raw-css.css?raw';
/**
* CSS file as static styles: the inline path.
*
* When the CSS *should* ship inside the JS bundle (one request, adopted
* stylesheet instead of a <link> fetch), `?raw` imports the file text and
* `unsafeCSS` wraps it for `static styles` — lit turns it into a constructed
* CSSStyleSheet adopted by every instance's shadow root.
*
* When the .css file changes, Vite's HMR invalidates the `?raw` module,
* which propagates to this component module. The module re-executes with
* the new text and the Lit HMR plugin hot-swaps the class's static styles
* on live instances.
*
* Caveat: `?raw` returns the file text verbatim, bypassing Vite's CSS
* pipeline — no Lightning CSS/PostCSS processing applies (unlike the
* `?hmr-url` demos), so stick to natively supported syntax here.
*
* Tradeoffs — inline, per component *class*. ✅ Bytes ride in the JS bundle:
* no runtime fetch, no FOUC; Lit caches one sheet per class, shared across all
* instances of *this* component. ❌ A *different* component type that inlines
* the same CSS gets its own sheet and its own copy of the bytes in its chunk;
* `?raw` skips the CSS pipeline. Right for a component's own styles — for a
* sheet shared across many types, import one shared sheet (see
* `hmr-shared-sheet` / `?css-sheet`) —
* https://oddcelot.github.io/vite-plugin-lit/guides/stylesheets/.
*/
@customElement('hmr-raw-css')
export class HmrRawCss extends LitElement {
static override styles = unsafeCSS(rawCss);
private renders = 0;
override render() {
return html`
<h2>Raw CSS</h2>
<div id="raw-box">styled via ?raw static styles</div>
<span class="badge" 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}`;
}
}
}

?raw skips Lightning CSS and PostCSS, so stick to syntax browsers accept natively. Swap it for ?inline when you want the pipeline. An edit re-executes the module and the plugin hot-swaps the class’s styles on live instances.

Build one sheet from inline text and share it

Section titled “Build one sheet from inline text and share it”

Inlining per component duplicates the bytes once per component type. Build the sheet once in its own module instead and the copies collapse to one, without the fetch a ?css-sheet import would make.

src/hmr-shared.css
.shared-card {
background: rgb(60, 80, 180);
color: #fff;
padding: 1rem;
border-radius: 8px;
font-weight: 600;
}
.shared-card:hover {
background: rgb(40, 60, 200);
}
.shared-badge {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.75em;
opacity: 0.8;
}
src/hmr-shared-sheet.ts
import rawCss from './hmr-shared.css?raw';
/**
* A single `CSSStyleSheet` adopted by both `hmr-shared-css-a` and
* `hmr-shared-css-b`. When the CSS file changes, this module handles HMR
* directly — it calls `replaceSync()` on the existing sheet, which updates
* all shadow roots that adopted it. The component modules are never
* re-executed, so their state and DOM are fully preserved.
*
* Tradeoffs — inline shared sheet, the benchmark's all-round winner. ✅ One
* parsed sheet shared by every adopter (fewest objects/nodes, fastest mount),
* no runtime fetch so **no FOUC**, and in-place HMR with no component
* re-render. ❌ Bytes ship in a JS chunk (not an independently cacheable
* asset), and `?raw` skips the CSS pipeline. When the bytes should stay a
* cacheable `.css` asset (e.g. a large generated utility sheet), use
* `?css-sheet` instead and accept a brief FOUC —
* https://oddcelot.github.io/vite-plugin-lit/guides/stylesheets/
* (benchmarked in bench/).
*/
const sheet = new CSSStyleSheet();
sheet.replaceSync(rawCss);
export default sheet;
if (import.meta.hot) {
import.meta.hot.accept(['./hmr-shared.css?raw'], ([mod]) => {
if (mod) {
sheet.replaceSync(mod.default as string);
}
});
}

The module accepts its own CSS dependency and calls replaceSync() on the existing sheet, so adopters restyle without re-executing. Components just import the sheet.

src/hmr-shared-css-a.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import sharedSheet from './hmr-shared-sheet.js';
/**
* First component sharing the `hmr-shared.css` stylesheet via a shared
* `CSSStyleSheet` object. Lit adopts the exact same sheet instance into
* both shadow roots — when the CSS changes, `replaceSync()` on that sheet
* updates all consumers without any component re-render.
*
* Compare with `hmr-linked-css` where a `<link>` href changes forces a
* full re-render on each HMR cycle.
*
* Approach benefits/problems: see `hmr-shared-sheet` (inline shared sheet).
*/
@customElement('hmr-shared-css-a')
export class HmrSharedCssA extends LitElement {
static override styles = [sharedSheet];
private renders = 0;
override render() {
return html`
<h2>Shared Sheet — A</h2>
<div class="shared-card">Component A</div>
<span class="shared-badge" 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}`;
}
}
}

?hmr-url is ?url with the cache-busting done for you. If you import ?url yourself, devCacheBust() is the helper that does it: in dev it appends a timestamp, and in a build it returns the content-hashed URL unchanged.

src/hmr-css-url.css
#url-box {
background: rgb(50, 120, 50);
color: #fff;
padding: 1rem;
border-radius: 4px;
}
src/hmr-css-url.ts
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import {devCacheBust} from '@oddsquad/vite-plugin-lit/css.js';
import cssUrl from './hmr-css-url.css?url';
/**
* External stylesheet loaded via <link> pointing at the real CSS file.
*
* The `?url` query parameter (Vite 5.4+) imports the URL of the CSS file
* itself. The dev server serves actual CSS at that URL (not the JS-wrapped
* module), so a <link> href can point straight at it.
*
* When the .css file changes, Vite invalidates the `?url` module and the
* update propagates to this component module, which re-executes and
* re-renders. But the imported URL string is the same on every execution,
* so without help the browser would keep the stale stylesheet —
* `devCacheBust` gives each module execution a fresh href, forcing a
* refetch.
*
* Tradeoffs — per-element delivery (the lower-level form of `hmr-linked-css`,
* wiring the `<link>` yourself). ✅ Real cacheable `.css` asset; full control
* of the href. ❌ One `<link>` + `CSSStyleSheet` per instance; CSS edits
* re-render the component. For a shared utility sheet prefer `?css-sheet` —
* see
* https://oddcelot.github.io/vite-plugin-lit/guides/stylesheets/
* (benchmarked in bench/).
*/
// Module scope: one fresh href per module execution, not per render.
const href = devCacheBust(cssUrl);
@customElement('hmr-css-url')
export class HmrCssUrl extends LitElement {
private renders = 0;
override render() {
return html`
<link rel="stylesheet" href="${href}" />
<h2>CSS ?url</h2>
<div id="url-box">styled via &lt;link&gt; to real file</div>
<span class="badge" 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}`;
}
}
}

Lit caches static styles per component class, so the cost of these shapes depends on how many component types use the same CSS. CSS delivery works through the numbers.