Gracile — Standard CSS Modules (Vite plugin)
Use import attributes in Vite to
get a
CSSStyleSheet
or a Lit CSSResult from your CSS
files.
// Standard CSS module — returns a CSSStyleSheet (client) or CSSResult (SSR)
import styles from './my-element.css' with { type: 'css' };
// Lit-specific — always returns a CSSResult
import styles from './my-element.css' with { type: 'css-lit' };
Works with CSS, SCSS, Sass, Less, Stylus, and PostCSS — anything Vite can process.
Caution
Experimental. This add-on is under active development and its API may change.
Concept
The CSS Module Scripts proposal
lets you import .css files as constructable CSSStyleSheet objects using
import … with { type: 'css' }. Browser support is growing but bundlers don’t
handle it natively yet.
This Vite plugin bridges the gap: it rewrites CSS import attributes at build
time so you can use the standard syntax today — with full support for Vite’s CSS
pipeline (PostCSS, Sass, Less, etc.) and automatic SSR fallback via Lit’s
unsafeCSS().
Installation
npm i vite-plugin-standard-css-modules
Peer dependencies
vite(5.x, 6.x, 7.x, or 8.x)lit(3.x) — optional, required only when usingtype: 'css-lit'or SSR
Setup
Add the plugin to your Vite (or Astro, Gracile, SvelteKit, etc.) config:
// vite.config.ts
import { standardCssModulesfunction standardCssModules(options?: Options): any } from 'vite-plugin-standard-css-modules';
export default {
pluginsplugins: any[] : [standardCssModulesfunction standardCssModules(options?: Options): any ()],
};
Usage
type: 'css' — Constructable stylesheet
On the client, the import is transformed into a
constructable CSSStyleSheet
populated via replaceSync():
import styles from './my-element.css' with { type: 'css' };
class MyElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [styles];
}
}
During SSR, CSSStyleSheet doesn’t exist in Node.js. The plugin
automatically falls back to Lit’s unsafeCSS(), returning a CSSResult instead
— no config needed.
type: 'css-lit' — Lit CSSResult
Always returns a Lit CSSResult, on both client and server. Use this when you
want a CSSResult everywhere (e.g. for Lit’s static styles):
import { LitElement, html } from 'lit';
import styles from './my-element.css' with { type: 'css-lit' };
class MyElement extends LitElement {
static styles = [styles];
render() {
return html`<p>Hello</p>`;
}
}
Lit handles both CSSStyleSheet and CSSResult in static styles, so you can
mix type: 'css' and type: 'css-lit' imports freely.
Options
standardCssModules({
include: ['**/*.{js,jsx,ts,tsx,mjs,mts,cjs,cts}'], // default
exclude: ['**/node_modules/**'], // default
outputMode: undefined, // default — auto-detect per import
log: false, // default
});
| Option | Type | Description |
|---|---|---|
include | string[] | Glob patterns for JS/TS files to transform. |
exclude | string[] | Glob patterns to skip. |
outputMode | 'CSSStyleSheet' | 'CSSResult' | Force every CSS import to produce a specific output, overriding per-import type attributes and SSR auto-detection. |
log | boolean | Print each transformed import to the console. |
outputMode
By default the plugin decides per import:
type: 'css'on the client →CSSStyleSheettype: 'css'during SSR →CSSResult(automatic fallback)type: 'css-lit'→CSSResulteverywhere
Setting outputMode overrides this globally:
// Always emit CSSResult (Lit's unsafeCSS), even on the client
standardCssModules({ outputMode: 'CSSResult' });
// Always emit CSSStyleSheet, even during SSR
// (make sure a CSSStyleSheet polyfill is available server-side)
standardCssModules({ outputMode: 'CSSStyleSheet' });
Pre/post-processors
All of Vite’s CSS pipelines work out of the box. Import SCSS, Sass, Less, Stylus, or PostCSS files with the same syntax:
import tokensmodule "*.scss" from './tokens.scss' with { type: 'css' };
import thememodule "*.less" from './theme.less' with { type: 'css-lit' };
TypeScript / IDE awareness
Add a triple-slash reference so your editor resolves CSS default imports as
CSSStyleSheet:
// src/vite-env.d.ts (or src/env.d.ts for Astro)
/// <reference types="vite-plugin-standard-css-modules/css-modules" />
/// <reference types="vite/client" />
See css-modules.d.ts for the full list of declared extensions.
How it works
- The plugin’s
transformhook runs withenforce: 'pre'. - OXC’s parser (
oxc-parser) scans the file’s AST forImportDeclarationnodes with awith { type: 'css' | 'css-lit' }attribute on a CSS-like file. - Each matching import is rewritten using
magic-string:- The CSS specifier gets a
?inlinequery appended so Vite processes it through its normal CSS pipeline (PostCSS, Sass, etc.) and returns the final CSS string. - For client +
type: 'css': anew CSSStyleSheet()is created and populated withreplaceSync(). - For
type: 'css-lit'or SSR:unsafeCSS()fromlitwraps the string into aCSSResult.
- The CSS specifier gets a
- In dev mode, the plugin injects
import.meta.hot.accepthandlers so CSS edits are applied instantly viaCSSStyleSheet.replaceSync()— no full page reload.
Hot Module Replacement (HMR)
During development (vite dev), the plugin provides graceful CSS swapping —
editing a CSS/SCSS/Less/… file updates styles in place without a full page
reload.
How it works
Because CSS is imported via ?inline (i.e. as a JS string module), a naïve
setup would cause Vite to treat every CSS change as a JS module change,
triggering an expensive full reload. This plugin avoids that by:
-
Dep-acceptance — each transformed import gets an
import.meta.hot.accept('./styles.css?inline', cb)block. Vite’s default HMR propagation sees that the JS module accepts its CSS dependency and calls the callback instead of reloading the page. -
In-place
replaceSync()— the callback callsCSSStyleSheet.replaceSync()on the same sheet instance that was already adopted into shadow roots. SinceadoptedStyleSheetsholds a reference, the update is instant and universal — every Web Component using that sheet sees the new styles immediately. -
Lit
CSSResultsupport — fortype: 'css-lit'imports, the callback accesses the lazily-cachedstyleSheetproperty on theCSSResultand callsreplaceSync()on the underlyingCSSStyleSheet. -
DSD (Declarative Shadow DOM) reconciliation — in dev mode the plugin prepends a CSS comment marker (
/* __csm:/src/path.css */) to the CSS content. A lightweight virtual module (virtual:standard-css-modules/hmr) traverses open shadow roots and updates any<style>elements containing the marker. This covers the window between initial SSR delivery and Lit hydration, where styles live as plain<style>tags rather thanadoptedStyleSheets.
No configuration needed — HMR is automatically enabled during
vite devand completely absent from production builds.
Why native CSS scoping makes HMR simpler
Full-stack frameworks like Next.js, Nuxt, Astro, and SvelteKit scope CSS
synthetically — they hash class names (.title → .title_abc123) or inject
attribute selectors (data-v-xxxx, .astro-xxxx, .svelte-xxxx). Because the
framework is the scoping layer, a CSS edit must flow back through the
framework’s pipeline: re-extract styles, re-hash class names, and often
re-render the component so the DOM picks up the new identifiers. For shared or
global stylesheets (Tailwind, Bootstrap, etc.), a single token change can force
a full CSS re-parse of the entire concatenated sheet since there is only one
global scope.
With Shadow DOM, the browser is the scoping layer, and
adoptedStyleSheets
holds a live reference to a CSSStyleSheet object. Updating styles reduces
to a single replaceSync() call — every shadow root that adopted the sheet sees
the change instantly, with no DOM manipulation, no class-name rebinding, and no
component re-render. The entire HMR implementation in this plugin is roughly 30
lines of generated code.
This mirrors the shift from the Webpack era to modern dev-servers (Snowpack,
@web/dev-server, Vite):
| Era | Module system | CSS scoping |
|---|---|---|
| Webpack | Bundler emulates ESM (__webpack_require__) — complex HMR plumbing | Framework emulates scoping (class hashing, attribute selectors) — complex reconciliation |
| Vite / native ESM | Browser runs ESM natively — HMR is trivial module replacement | Browser runs Shadow DOM natively — HMR is a single replaceSync() |
By relying on what the platform already provides, both module loading and CSS scoping become dramatically simpler to hot-update — no framework orchestration needed, just hand the browser the new content and let the native infrastructure propagate it.