cmseguin/rollup-plugin-react-scoped-css(新しいタブで開きます)
主要言語: TypeScript
A rollup plugin designed to allow scoped css to be run in react (Compatible with vite and rollup)
- star 数
- 46
- watcher 数
- 3
- fork 数
- 6
- issue 数
- 0
- 最終更新
- 2026/10/26
主要言語: TypeScript
A rollup plugin designed to allow scoped css to be run in react (Compatible with vite and rollup)
While using react in a professional context, I realized that it was lacking the scoped css feature that I learned to love from Vue and Angular. After some research I came across good plugins, but sadly were not compatible with vite and/or rollup. Thus, I decided to create this plugin.
$ npm i rollup-plugin-react-scoped-css
in vite:
// vite.config.js import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { reactScopedCssPlugin } from 'rollup-plugin-react-scoped-css' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), reactScopedCssPlugin()] })
without vite (rollup or rolldown):
// rollup.config.js / rolldown.config.js import { reactScopedCssPlugin } from 'rollup-plugin-react-scoped-css'; // The plugin returns two halves that must run before and after your JSX and // CSS plugins. const [scopedCssPre, scopedCssPost] = reactScopedCssPlugin(); export default { input: 'src/main.tsx', plugins: [scopedCssPre, /* JSX + CSS plugins go here */, scopedCssPost], };
Notes: Because this plugin has been built with vite as its primary usecase, it doesn't transpile JSX/TypeScript, LESS, SCSS or other preprocessors, and it doesn't process CSS imports. When you use Rollup or Rolldown directly, you need to plug in those pieces between the two halves of the plugin. See Using without Vite for complete, working configs.
There are a few options available to customize how the plugin works
{ /** * Which files should be included and parsed by the plugin * Default: undefined */ include?: FilterPattern; /** * Which files should be excluded and that should not be parsed by the plugin * Default: undefined */ exclude?: FilterPattern; /** * If you want regular files to be scoped & global files to be .global.css * Default: false */ scopeStyleByDefault?: boolean; /** * If you want to customize the pattern for scoped styles. * This will only work if scopeStyleByDefault is false * Default: 'scoped' */ scopedStyleSuffix?: string; /** * If you want to customize the pattern for global styles. * This will only work if scopeStyleByDefault is true * Default: 'global' */ globalStyleSuffix?: string; /** * If you want to customize the pattern for style files. * Default: ['css', 'scss', 'sass', 'less'] */ styleFileExtensions?: string[]; /** * If you want to customize the pattern for jsx files. * Default: ['jsx', 'tsx'] */ jsxFileExtensions?: string[]; /** * If you want to customize the attribute prefix that is added to the jsx elements * Default: 'v' */ hashPrefix?: string; }
The plugin works in two halves:
scopedCssPre rewrites scoped style imports such as
./Component.scoped.css to ./Component.scoped.css?scope=<hash>.scopedCssPost scopes the CSS and adds the matching data-<hash> attributes
to the compiled React output.Vite orders these automatically through the enforce option. With Rollup or
Rolldown, enforce is ignored, so you must place them in the plugins array
yourself and put the following between them:
React.createElement() or _jsx() calls, not raw JSX. Babel (shown below) or
any equivalent transpiler works..css imports
on their own. The bridge below reads the file, lets scopedCssPost transform
it, then emits the transformed CSS and replaces the import with an empty
module.The bridge is needed when nothing else in your build turns .css imports into
JavaScript. Neither Rollup nor Rolldown does that on its own, so a plain config
fails as soon as it reaches the first style import.
Use the bridge when:
You can skip it when:
.css imports. Put that plugin between
scopedCssPre and scopedCssPost, and make sure it accepts the
?scope=<hash> query the plugin adds to scoped imports.The bridge only does extraction. If you need style injection, CSS modules, preprocessor support, or per-chunk styles, use a dedicated CSS plugin instead.
Save this next to your config:
// scoped-css-bridge.js import { readFile } from 'node:fs/promises' const stripQuery = (id) => id.split('?')[0] const isCss = (id) => stripQuery(id).endsWith('.css') export function scopedCssBridge() { const styles = new Map() return [ { name: 'scoped-css:load', async load(id) { if (isCss(id)) { return readFile(stripQuery(id), 'utf8') } }, }, { name: 'scoped-css:emit', transform(code, id) { if (!isCss(id)) return styles.set(stripQuery(id), code) return { code: 'export default undefined', map: null } }, generateBundle() { this.emitFile({ type: 'asset', fileName: 'styles.css', source: [...styles.values()].join('\n'), }) }, }, ] }
npm i -D rollup @babel/core @babel/preset-react @babel/preset-typescript \ @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve \ @rollup/plugin-replace
// rollup.config.js import babel from '@rollup/plugin-babel' import commonjs from '@rollup/plugin-commonjs' import { nodeResolve } from '@rollup/plugin-node-resolve' import replace from '@rollup/plugin-replace' import { reactScopedCssPlugin } from 'rollup-plugin-react-scoped-css' import { scopedCssBridge } from './scoped-css-bridge.js' const [scopedCssPre, scopedCssPost] = reactScopedCssPlugin() const [cssLoader, cssEmitter] = scopedCssBridge() export default { input: 'src/main.tsx', output: { dir: 'dist', format: 'esm', entryFileNames: 'main.js', sourcemap: true, }, plugins: [ scopedCssPre, babel({ babelHelpers: 'bundled', extensions: ['.js', '.jsx', '.ts', '.tsx'], exclude: '**/node_modules/**', presets: [ ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], }), cssLoader, scopedCssPost, cssEmitter, replace({ preventAssignment: true, values: { 'process.env.NODE_ENV': JSON.stringify('production') }, }), nodeResolve({ extensions: ['.js', '.jsx', '.ts', '.tsx'] }), commonjs(), ], }
nodeResolve and commonjs are only needed if React is bundled rather than
loaded externally. replace is needed because React's CommonJS build checks
process.env.NODE_ENV at runtime.
Rolldown resolves node_modules and bundles CommonJS itself, but Babel still has
to run before scopedCssPost, and CSS has to be treated as JavaScript for the
bridge to take over.
npm i -D rolldown @babel/core @babel/preset-react @babel/preset-typescript \ @rollup/plugin-babel
// rolldown.config.js import { defineConfig } from 'rolldown' import babel from '@rollup/plugin-babel' import { reactScopedCssPlugin } from 'rollup-plugin-react-scoped-css' import { scopedCssBridge } from './scoped-css-bridge.js' const [scopedCssPre, scopedCssPost] = reactScopedCssPlugin() const [cssLoader, cssEmitter] = scopedCssBridge() export default defineConfig({ input: 'src/main.tsx', platform: 'browser', // Rolldown no longer bundles CSS itself. The bridge emits it instead. moduleTypes: { '.css': 'js' }, transform: { define: { 'process.env.NODE_ENV': JSON.stringify('production') }, }, plugins: [ scopedCssPre, babel({ babelHelpers: 'bundled', extensions: ['.js', '.jsx', '.ts', '.tsx'], exclude: '**/node_modules/**', presets: [ ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], }), cssLoader, scopedCssPost, cssEmitter, ], output: { dir: 'dist', format: 'esm', entryFileNames: 'main.js', sourcemap: true, }, })
<link rel="stylesheet" href="./styles.css"> and
<script type="module" src="./main.js"></script>.rollup-plugin-postcss instead of the bridge,
make sure it accepts the ?scope=<hash> query that scopedCssPre adds and
runs between the two halves of the plugin.scopedCssPost.
Compile them to CSS inside the bridge (or add a preprocessor plugin between
cssLoader and scopedCssPost) so the scoper receives plain CSS.scopedCssPost must stay after your JSX transpiler and after the CSS loader,
and the CSS emitter must stay after scopedCssPost.// Component.jsx import './Component.scoped.scss' export default function Sub() { return ( <div className="wrap"> <h1>My Component</h1> </div> ) }
// Component.scoped.scss .wrap { width: 500px; h1 { color: red; } }
And just like that the styles will be scoped to the component.
Due to the way this plugin is working, it will apply the scope to the file and not the component individually... This may differ from other frameworks since they don't really let you define multiple components in the same file. This then means that if you have 2 components in the same file, the styles might conflict.
If you want a selector in scoped styles to be "deep", i.e. affecting child components, you can use the ::deep combinator:
.a::deep .b { /* ... */ }
The above will be compiled into:
.a[data-f3f3eg9] .b { /* ... */ }
Another expected format, which will generate the same result, is:
.a::v-deep .b { /* ... */ }
This is primarily for backwards compatibility, we recommend the ::deep selector.
DOM content created with dangerouslySetInnerHTML are not affected by scoped styles, but you can still style them using deep selectors.
Scoped styles do not eliminate the need for classes. Due to the way browsers render various CSS selectors, p { color: red } will be many times slower when scoped (i.e. when combined with an attribute selector). If you use classes or ids instead, such as in .example { color: red }, then you virtually eliminate that performance hit.
Be careful with descendant selectors in recursive components! For a CSS rule with the selector .a .b, if the element that matches .a contains a recursive child component, then all .b in that child component will be matched by the rule.
npm testThe e2e harness packs the plugin with npm pack, installs it into isolated
projects, then drives a real browser against each setup:
vite preview.@rollup/plugin-babel, the CSS bridge
documented above, and the bundled React runtime, served by a static Node
server.Prerequisites: Node >= 20.19 and a Playwright browser.
npm run test:e2e:install # once; installs Chromium npm run test:e2e # all bundlers (Vite matrix + Rollup + Rolldown) npm run test:e2e:vite # Vite only npm run test:e2e:bundlers # Rollup and Rolldown only
Options (pass-through after --):
npm run test:e2e -- --bundler rollup # vite, rollup, rolldown, or all (default) npm run test:e2e -- --bundler vite --vite 7 # one Vite major npm run test:e2e -- --mode dev # dev, build, or both (Vite only) npm run test:e2e -- --browser firefox # chromium (default), firefox, webkit npm run test:e2e -- --headed # watch the browser npm run test:e2e -- --clean --force # wipe and reinstall cached installs npm run test:e2e -- --keep-temp # keep production build output npm run test:e2e -- --allow-broken-sourcemap
--vite and --mode only affect the Vite matrix. The Rollup and Rolldown
setups run production builds only, so --mode dev fails when either is
selected.
The harness asserts that:
data-<hash> attributes and every element from a file
shares that file's hash;.tsx files and include their content;SOURCEMAP_BROKEN warnings.All installs are cached in e2e/.cache/; the cache key includes the packed
plugin, so plugin changes trigger a reinstall. Vite 8 uses Rolldown rather than
Rollup; rollup is installed in the Vite projects only to satisfy this
package's peer dependency. The e2e CI job runs npm run test:e2e, covering
all three bundlers.
Anyone is free to open a PR and contribute to this project... just be civilized!
Inspired by react-scoped-css.