@knighted/css/loader lets bundlers attach compiled CSS strings to any module by appending the ?knighted-css query when importing. The loader mirrors the module graph, compiles every CSS dialect it discovers (CSS, Sass, Less, vanilla-extract, etc.), and exposes the concatenated result as knightedCss.
Use this table to decide which loader you need before wiring up rules:
| Capability | @knighted/css/loader | @knighted/css/loader-bridge |
|---|---|---|
| Input | Original JS/TS module source + its style imports | Compiled CSS Modules output (post-hash) |
| CSS extraction | Yes (walks the import graph) | No (wraps upstream output) |
| Export behavior | Appends knightedCss (and optional selector exports) onto the original module |
Exposes knightedCss/knightedCssModules only; does not re-export JS/TS module exports |
| When to use | Default choice for ?knighted-css in JS/TS modules |
When you need hashed CSS Modules output for runtime knightedCss |
| Combined wrapper needed? | Only for explicit ?knighted-css&combined usage |
Yes if you still need original JS/TS exports (use &combined via the resolver plugin) |
import { knightedCss } from './button.js?knighted-css'
export const styles = knightedCssAdd a bundler rule that pipes ?knighted-css imports through @knighted/css/loader plus your transpiler of choice. See the main README for a complete rule configuration.
// rspack.config.js
export default {
module: {
rules: [
{
test: /\.[jt]sx?$/,
resourceQuery: /knighted-css/,
use: [
{
loader: '@knighted/css/loader',
options: {
lightningcss: { minify: true }, // all css() options supported
},
},
],
},
],
},
}knighted-css-generate-types supports two modes. Both are fully supported and tested; the right choice depends on how explicit you want the imports to be versus how much resolver automation you want to lean on:
--mode module(double-extension imports): use.knighted-csssidecar modules such asimport stableSelectors from './button.css.knighted-css.js'. This keeps resolution explicit and tends to be the most stable under large, complex builds.--mode declaration(idiomatic imports): emit.d.tssidecars next to the original JS/TS module and keep clean imports likeimport { knightedCss } from './button.js'. This is cleaner at call sites, but it adds resolver work at build time and depends on the resolver plugin to stay in sync.
If you want the simplest, most transparent build behavior, start with --mode module.
If you want cleaner imports and are comfortable with resolver automation, choose --mode declaration and enable strict sidecars + a manifest for safety.
When you use knighted-css-generate-types --mode declaration, TypeScript expects the
augmented exports to be present on the original JS/TS module. Use the resolver plugin
to automatically append ?knighted-css for any module import that has a generated
sidecar .d.ts file.
See docs/plugin.md for full resolver plugin documentation.
// rspack.config.js
import { knightedCssResolverPlugin } from '@knighted/css/plugin'
export default {
resolve: {
plugins: [knightedCssResolverPlugin()],
},
}// webpack.config.js
const { knightedCssResolverPlugin } = require('@knighted/css/plugin')
module.exports = {
resolve: {
plugins: [knightedCssResolverPlugin()],
},
}If you use declaration mode, consider enabling strict sidecar detection with a manifest. This
ensures only .d.ts files generated by knighted-css-generate-types trigger rewrites:
import path from 'node:path'
import { knightedCssResolverPlugin } from '@knighted/css/plugin'
export default {
resolve: {
plugins: [
knightedCssResolverPlugin({
strictSidecar: true,
manifestPath: path.resolve('.knighted-css/knighted-manifest.json'),
}),
],
},
}Note
The loader shares the same auto-configured oxc-resolver as the standalone css() API, so hash-prefixed specifiers declared under package.json#imports (for example, #ui/button) resolve without additional options.
Tip
Sass-only aliases such as pkg:#button never hit Node resolution. Add a small shim resolver (see docs/sass-import-aliases.md) when you need to rewrite those specifiers before the loader runs.
Pass autoStable to duplicate every matching class selector with a deterministic namespace (default knighted-). This runs without PostCSS and works for both plain CSS and CSS Modules:
{
loader: '@knighted/css/loader',
options: {
autoStable: true, // or { namespace: 'myapp', include: /button|card/, exclude: /legacy/ }
},
}- Plain CSS:
.foo {}becomes.foo, .knighted-foo {}. - CSS Modules: exports and generated class strings include both the hashed class and the stable class so you can reference either at runtime.
autoStableforces a LightningCSS pass; useinclude/excludeto scope which class tokens are duplicated.
When you want knightedCss to reflect the hashed class names produced by your existing
CSS Modules pipeline, use the companion loader @knighted/css/loader-bridge. It runs
after your Sass/CSS modules loaders and simply wraps their output (no reprocessing).
The key distinction:
@knighted/css/loaderworks from source styles (pre-hash). It resolves imports, compiles CSS dialects, and emitsknightedCssbefore any downstream CSS Modules hashing/compilation happens.@knighted/css/loader-bridgeworks from compiled output (post-hash). It assumes your CSS Modules pipeline already ran and therefore must be chained after loaders likecss-loader,sass-loader, orless-loader.
// rspack.config.js or webpack.config.js
export default {
module: {
rules: [
{
test: /\.module\.(css|scss|sass)$/,
oneOf: [
{
resourceQuery: /knighted-css/,
use: [
{
loader: '@knighted/css/loader-bridge',
},
{
loader: 'css-loader',
options: {
exportType: 'string',
modules: true,
},
},
'sass-loader',
],
},
{
use: [
{
loader: 'css-loader',
options: {
modules: true,
},
},
'sass-loader',
],
},
],
},
],
},
}import { knightedCss, knightedCssModules } from './card.module.scss?knighted-css'
// knightedCss uses the same hashed selectors as the DOM
shadowRoot.adoptedStyleSheets[0].replaceSync(knightedCss)
// optional convenience mapping of the CSS module locals
console.log(knightedCssModules)Note
The bridge loader does not generate stableSelectors. It simply re-exports the
upstream output and resolves knightedCss by calling the upstream module’s
toString() or using its default string export when present. For CSS Modules
pipelines, configure the ?knighted-css branch to emit a string (for example,
css-loader with exportType: 'string') so knightedCss is populated.
Need the component exports and the compiled CSS from a single import? Use ?knighted-css&combined and narrow the result with KnightedCssCombinedModule to keep TypeScript happy:
import type { KnightedCssCombinedModule } from '@knighted/css/loader'
import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers'
import * as buttonModule from './button.js?knighted-css&combined'
const { default: Button, knightedCss } =
asKnightedCssCombinedModule<typeof import('./button.js')>(buttonModule)
// Need to describe additional loader-injected exports (for example, `stableSelectors` when
// using `?knighted-css&combined&types`)? Pass a second generic:
const {
default: ButtonWithSelectors,
knightedCss: buttonCss,
stableSelectors,
} = asKnightedCssCombinedModule<
typeof import('./button.js'),
{ stableSelectors: Record<string, string> }
>(buttonModule)Append &named-only (alias: &no-default) if you never consume the default export. Refer to docs/combined-queries.md for the full matrix of query flags and destructuring patterns.
Tip
@knighted/css/loader-helpers ships the asKnightedCssCombinedModule helper in isolation so you can safely import it from browser bundles. This keeps the heavy loader implementation (and its Node dependencies) out of client builds.
When you need the runtime stableSelectors map alongside knightedCss, append &types to either the plain or combined import:
import { knightedCss, stableSelectors } from './card.js?knighted-css&types'Note
TypeScript does not infer the stable selector literal types from this import; use the generated .knighted-css.* modules described in docs/type-generation.md for compile-time safety. The runtime map is helpful for tests, telemetry, or non-TypeScript environments.
vanilla-extract files (*.css.ts) compile down to CommonJS by default. That works out of the box for the loader—both ?knighted-css and ?knighted-css&combined queries emit module.exports artifacts plus the injected knightedCss string. Most bundlers happily consume that shape. When you also need the compiled module to behave like a native ESM module (e.g., your bundler expects export statements so it can treeshake or when you import via extension aliases), enable the loader’s opt-in transform:
{
test: /\.css\.ts$/,
use: [
{
loader: '@knighted/css/loader',
options: {
lightningcss: { minify: true },
vanilla: { transformToEsm: true },
},
},
],
}The vanilla.transformToEsm flag runs a small post-pass that strips the CJS boilerplate emitted by @vanilla-extract/integration and re-exports the discovered bindings via native export { name } statements. That makes combined imports behave exactly like the source module, which is useful for frameworks that rely on strict ESM semantics (our Lit + React Playwright app is the canonical example in this repo).
Important
Only enable vanilla.transformToEsm when your bundler really requires ESM output. Leaving the transform off keeps the vanilla-extract module identical to what the upstream compiler produced, which is often preferable if the rest of your toolchain expects CommonJS. The loader no longer toggles this transform automatically—combined imports stay fast, but you remain in full control of when the conversion occurs.
If your build pipeline can gracefully consume both module syntaxes (for example, webpack or Rspack projects that treat the vanilla-extract integration bundle as CommonJS), you may get the desired behavior simply by forcing those files through the “auto” parser instead of rewriting them:
{
test: /@vanilla-extract\/integration/,
type: 'javascript/auto',
}That hint keeps the upstream CommonJS helpers intact while still letting the rest of your app compile as native ESM. It’s worth trying first if you’d rather avoid the transform and your bundler already mixes module systems without issue. Flip vanilla.transformToEsm back on whenever you hit a toolchain that insists on pure ESM output.