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 using type: 'css-lit' or SSR

Setup

Add the plugin to your Vite (or Astro, Gracile, SvelteKit, etc.) config:

// vite.config.ts
import { standardCssModules } from 'vite-plugin-standard-css-modules';

export default {
  plugins: [standardCssModules()],
};

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
});
OptionTypeDescription
includestring[]Glob patterns for JS/TS files to transform.
excludestring[]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.
logbooleanPrint each transformed import to the console.

outputMode

By default the plugin decides per import:

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 tokens from './tokens.scss' with { type: 'css' };
import theme 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

  1. The plugin’s transform hook runs with enforce: 'pre'.
  2. OXC’s parser (oxc-parser) scans the file’s AST for ImportDeclaration nodes with a with { type: 'css' | 'css-lit' } attribute on a CSS-like file.
  3. Each matching import is rewritten using magic-string:
    • The CSS specifier gets a ?inline query appended so Vite processes it through its normal CSS pipeline (PostCSS, Sass, etc.) and returns the final CSS string.
    • For client + type: 'css': a new CSSStyleSheet() is created and populated with replaceSync().
    • For type: 'css-lit' or SSR: unsafeCSS() from lit wraps the string into a CSSResult.
  4. In dev mode, the plugin injects import.meta.hot.accept handlers so CSS edits are applied instantly via CSSStyleSheet.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:

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

  2. In-place replaceSync() — the callback calls CSSStyleSheet.replaceSync() on the same sheet instance that was already adopted into shadow roots. Since adoptedStyleSheets holds a reference, the update is instant and universal — every Web Component using that sheet sees the new styles immediately.

  3. Lit CSSResult support — for type: 'css-lit' imports, the callback accesses the lazily-cached styleSheet property on the CSSResult and calls replaceSync() on the underlying CSSStyleSheet.

  4. 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 than adoptedStyleSheets.

No configuration needed — HMR is automatically enabled during vite dev and 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):

EraModule systemCSS scoping
WebpackBundler emulates ESM (__webpack_require__) — complex HMR plumbingFramework emulates scoping (class hashing, attribute selectors) — complex reconciliation
Vite / native ESMBrowser runs ESM natively — HMR is trivial module replacementBrowser 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.