# Content Script CSS Injection in CRXJS: How the Vite Plugin Automates Manifest CSS

> Learn how the CRXJS Vite plugin automatically injects content script CSS into manifest json eliminating manual CSS entry. Discover the automation process in detail.

- Repository: [crxjs/chrome-extension-tools](https://github.com/crxjs/chrome-extension-tools)
- Tags: internals
- Published: 2026-02-28

---

**The CRXJS Vite plugin automatically discovers CSS imports from your content scripts and injects them into manifest.json during the `renderCrxManifest` hook, eliminating manual CSS entry maintenance.**

The **crxjs/chrome-extension-tools** repository provides a specialized Vite plugin that automates the tedious task of tracking CSS dependencies for Chrome extension content scripts. Located at [`packages/vite-plugin/src/node/plugin-contentScripts_css.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/plugin-contentScripts_css.ts), the `pluginContentScriptsCss` function bridges Vite's module graph with Chrome's manifest format. This article examines the exact implementation details of how the plugin discovers, validates, and injects CSS assets into the final extension manifest.

## How plugin-contentScripts_css.ts Works

The plugin operates as a three-stage pipeline that reads user configuration, traverses the manifest during the build process, and mutates entries to include discovered CSS files. It runs with `enforce: 'post'` to ensure it executes after other CRX plugins have populated the internal metadata stores.

### Plugin Registration and Configuration

At lines 8–10, the plugin registers itself with Vite using a namespaced identifier and explicit execution order:

```typescript
name: 'crx:content-scripts-css',
enforce: 'post'

```

The `post` enforce value guarantees the plugin runs after the core content script processing completes. During the `config` hook (lines 11–14), the plugin retrieves the complete CRX configuration via `getOptions` and extracts the user preference:

```typescript
let injectCss: boolean
// ...
injectCss = contentScripts.injectCss ?? true

```

This defaults to `true` when the user omits the `contentScripts.injectCss` option, enabling automatic injection by default.

### Manifest Transformation Logic

The heart of the plugin resides in the `renderCrxManifest` hook (lines 15–28). This hook receives the partially constructed manifest and mutates it in-place before Vite writes the final output:

1. **Guard clauses**: The plugin first checks `if (injectCss)` (line 16) and `if (manifest.content_scripts)` (lines 17–18) to skip processing when disabled or when no content scripts exist.
2. **Script iteration**: It walks the manifest array with `for (const script of manifest.content_scripts)` (lines 19–20).
3. **File name resolution**: For each script, it checks `if (script.js)` and iterates `for (const fileName of script.js)` (lines 21–22) to handle cases where a single content script entry contains multiple JavaScript files.

### Metadata Lookup and Error Handling

For every JavaScript file referenced in the manifest, the plugin performs a strict lookup against the internal `contentScripts` map (lines 23–27):

```typescript
if (contentScripts.has(fileName)) {
  const { css } = contentScripts.get(fileName)!
  if (css?.length) script.css = [script.css ?? [], css].flat()
} else {
  throw new Error(`Content script is undefined by fileName: ${fileName}`)
}

```

This lookup retrieves the `ContentScript` metadata object containing the `css?: string[]` array populated during earlier build phases. If the file name is not registered—indicating a typo or build misconfiguration—the plugin throws an explicit error rather than silently omitting CSS entries.

### CSS Array Merging

At lines 26–27, the plugin handles both newly discovered and pre-existing CSS entries:

```typescript
if (css?.length) script.css = [script.css ?? [], css].flat()

```

This expression preserves any manually declared `css` arrays in the user's manifest while appending auto-discovered imports, flattening the result into a single consolidated array for Chrome to inject at runtime.

## The contentScripts Metadata Store

The injection mechanism relies on the **RxMap** defined in [`packages/vite-plugin/src/node/contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/packages/vite-plugin/src/node/contentScripts.ts). This reactive map stores `ContentScript` objects indexed by multiple keys—including `fileName`, `id`, and `loaderName`—to ensure lookups succeed regardless of how the script was referenced.

According to lines 9–28 of [`contentScripts.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/contentScripts.ts), the `ContentScript` interface includes:

```typescript
export interface ContentScript {
  // ...
  css?: string[]
}

```

The map subscription logic (lines 43–66) mirrors each entry under alternative keys, enabling the `contentScripts.has(fileName)` check to work reliably even when scripts are referenced by different identifiers throughout the build pipeline.

## Configuration Options

The `contentScripts.injectCss` boolean controls the entire injection behavior. When set to `false`, the plugin bypasses all CSS processing and returns the manifest unmodified, allowing developers to manually curate their CSS arrays.

## Practical Implementation Examples

### Default Auto-Injection Behavior

When `injectCss` is omitted or set to `true`, CSS imports are discovered automatically:

```typescript
// crx.config.ts
import { defineCrxManifest } from 'chrome-extension-tools';

export default defineCrxManifest({
  manifest: {
    name: 'My Extension',
    version: '1.0',
    content_scripts: [
      {
        matches: ['<all_urls>'],
        js: ['src/content/main.ts'], // imports './styles.css'
      },
    ],
  },
});

```

The plugin detects that [`main.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/main.ts) imports [`styles.css`](https://github.com/crxjs/chrome-extension-tools/blob/main/styles.css), stores this relationship in the `contentScripts` map, and emits a manifest containing `"css": ["src/content/styles.css"]`.

### Disabling Automatic Injection

To prevent automatic CSS injection and maintain manual control:

```typescript
// crx.config.ts
export default defineCrxManifest({
  manifest: {
    content_scripts: [{
      matches: ['<all_urls>'],
      js: ['src/content/main.ts'],
    }],
  },
  contentScripts: {
    injectCss: false,
  },
});

```

The resulting [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json) omits the `css` field entirely for this entry, even if the script imports CSS modules.

### Merging with Existing CSS Entries

When you pre-declare CSS entries alongside auto-discovered imports:

```typescript
// crx.config.ts
export default defineCrxManifest({
  manifest: {
    content_scripts: [
      {
        matches: ['https://example.com/*'],
        js: ['src/content/extra.ts'],
        css: ['custom/override.css'],
      },
    ],
  },
});

```

If [`extra.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/extra.ts) also imports [`extra.css`](https://github.com/crxjs/chrome-extension-tools/blob/main/extra.css), the plugin produces a merged array:

```json
"css": ["custom/override.css", "src/content/extra.css"]

```

## Summary

- **Automatic Discovery**: The plugin traverses the Vite module graph to find CSS imports within content scripts and injects them into [`manifest.json`](https://github.com/crxjs/chrome-extension-tools/blob/main/manifest.json) without manual configuration.
- **Strict Validation**: The plugin throws `Content script is undefined by fileName: ${fileName}` if a manifest entry references a script not registered in the internal `contentScripts` map, preventing silent build failures.
- **Non-Destructive Merging**: Pre-existing `css` arrays in the manifest are preserved and concatenated with auto-discovered entries using `Array.flat()`.
- **Configurable Behavior**: The `contentScripts.injectCss` option (default `true`) allows developers to disable automatic injection when manual CSS management is preferred.
- **Execution Timing**: With `enforce: 'post'`, the plugin runs after other CRX plugins have populated the `contentScripts` metadata store, ensuring accurate lookups.

## Frequently Asked Questions

### How do I disable automatic CSS injection in CRXJS?

Set `contentScripts.injectCss` to `false` in your CRX configuration. When disabled, the `renderCrxManifest` hook returns the manifest unmodified, and you must manually specify all CSS files in your `manifest.content_scripts` entries.

### What happens if a content script file is not found during CSS injection?

The plugin throws a clear runtime error: `Content script is undefined by fileName: ${fileName}`. This occurs at lines 23–27 of [`plugin-contentScripts_css.ts`](https://github.com/crxjs/chrome-extension-tools/blob/main/plugin-contentScripts_css.ts) when the `contentScripts` map lacks an entry for a file name listed in your manifest's `js` array, typically indicating a typo or build configuration issue.

### Can I combine manually declared CSS with auto-injected CSS?

Yes. The plugin uses `[script.css ?? [], css].flat()` to merge arrays. Any CSS entries you manually define in the manifest are preserved, and discovered imports are appended to create a unified list for Chrome to inject.

### When does the CSS injection plugin run during the Vite build?

The plugin executes during the `renderCrxManifest` hook with `enforce: 'post'`, ensuring it runs after the core content script plugins have analyzed the module graph and populated the `contentScripts` RxMap with CSS metadata.