Native CSS
This guide shows how to use webpack's native CSS handling with experiments.css, and how to migrate an existing setup off css-loader, style-loader, and mini-css-extract-plugin.
Getting Started
Enable native CSS support in your webpack configuration:
webpack.config.js
export default {
experiments: {
css: true,
},
};With this option enabled, webpack understands .css files as first-class modules — parsing @import and url(), extracting stylesheets, generating content hashes, and supporting CSS Modules — without css-loader, style-loader, or mini-css-extract-plugin.
Importing CSS
After enabling the experiment, import .css files directly from JavaScript:
src/index.js
import "./styles.css";
const element = document.createElement("h1");
element.textContent = "Hello native CSS";
document.body.appendChild(element);src/styles.css
h1 {
color: #1f6feb;
}Webpack processes the CSS and includes it in the build output.
CSS module types
Native CSS introduces four Rule.type values. Knowing which one applies is the key to migrating, because each maps to a different css-loader modules.mode:
| Type | Scoping | css-loader equivalent |
|---|---|---|
css | Global, no CSS Modules parsing | modules: false |
css/global | Global selectors, but :local() is honored | modules.mode: 'global' |
css/module | Local by default, :global() escapes to global | modules.mode: 'local' |
css/auto | Picks css/module for *.module.css / *.modules.css, otherwise css/global | modules.auto: true |
The default rule webpack adds for /\.css$/i is css/auto, so *.module.css files become CSS Modules and everything else stays global — matching the most common css-loader configuration out of the box.
CSS Modules
With css/auto, name a file *.module.css (or *.modules.css) to opt it into CSS Modules:
src/button.module.css
.button {
background: #0d6efd;
color: white;
border: 0;
border-radius: 4px;
padding: 8px 12px;
}src/index.js
import * as styles from "./button.module.css";
const button = document.createElement("button");
button.className = styles.button;
button.textContent = "Click me";
document.body.appendChild(button);You can customize CSS Modules behavior with parser and generator options — see All options with examples below:
webpack.config.js
export default {
experiments: {
css: true,
},
module: {
parser: {
"css/auto": {
namedExports: true,
},
},
generator: {
"css/auto": {
exportsConvention: "camel-case-only",
localIdentName: "[uniqueName]-[id]-[local]",
},
},
},
};Supported CSS Modules features
Native CSS Modules understand the same authoring features as css-loader, so most stylesheets migrate unchanged:
composes— compose one local class from another (includingcomposes: foo from "./other.module.css"); the export resolves to the space-separated list of class names.@value— declare and import reusable values (@value primary: #1f6feb;,@value primary from "./vars.module.css").:export— expose arbitrary key/value pairs to JavaScript.:local()/:global()— switch scoping inline within any module type.
/* button.module.css */
@value brand: #1f6feb;
.base {
padding: 8px 12px;
}
.primary {
composes: base;
background: brand;
}
:export {
brandColor: brand;
}Output modes (exportType)
A single CSS module can be emitted in four ways. The exportType parser option selects which one, and each replaces a different piece of the classic toolchain:
exportType | Behavior | Replaces |
|---|---|---|
"link" (default) | Extracts a .css file, loaded via <link> | mini-css-extract-plugin |
"style" | Injects a <style> element from the runtime | style-loader |
"text" | Exports the CSS as a string | css-loader exportType: 'string' |
"css-style-sheet" | Exports a constructable CSSStyleSheet | css-loader exportType: 'css-style-sheet' |
Set it globally per module type:
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};or per rule for a subset of files:
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.css$/i,
type: "css/auto",
parser: { exportType: "style" },
},
],
},
};Migration Guide
At a glance
| Legacy setup | Native equivalent |
|---|---|
mini-css-extract-plugin (MiniCssExtractPlugin.loader) | built-in extraction (default exportType: "link") |
MiniCssExtractPlugin filename / chunkFilename | output.cssFilename / output.cssChunkFilename |
style-loader | exportType: "style" |
css-loader | built-in CSS parsing (no loader needed) |
css-loader url / import | module.parser.css.url / import (both default true) |
css-loader modules (.module.css auto-detect) | css/auto module type |
css-loader modules.mode | css/module / css/global type + pure |
css-loader modules.localIdentName | generator localIdentName |
css-loader modules.exportLocalsConvention | generator exportsConvention |
css-loader modules.namedExport | module.parser.css.namedExports (default true) |
css-loader modules.exportOnlyLocals | generator exportsOnly |
css-loader esModule | generator esModule (default true) |
css-loader exportType: 'string' / 'css-style-sheet' | exportType: "text" / "css-style-sheet" |
Migrate one loader at a time — the sections below go in the order that keeps the build green at each step.
1. Start from a classic setup
webpack.config.js
import MiniCssExtractPlugin from "mini-css-extract-plugin";
export default {
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [new MiniCssExtractPlugin()],
};2. Enable native CSS
webpack.config.js
export default {
experiments: {
css: true,
},
};The built-in /\.css$/i → css/auto rule now handles .css imports. Remove your custom rule and plugin once the following sections confirm each option has an equivalent.
3. Replace mini-css-extract-plugin
Native CSS extracts stylesheets and adds content hashes to them by default (exportType: "link"), so the plugin and its loader are no longer needed:
webpack.config.js
-import MiniCssExtractPlugin from "mini-css-extract-plugin";
-
export default {
+ experiments: {
+ css: true,
+ },
- module: {
- rules: [
- {
- test: /\.css$/i,
- use: [MiniCssExtractPlugin.loader, "css-loader"],
- },
- ],
- },
- plugins: [new MiniCssExtractPlugin()],
};Map the remaining plugin options:
mini-css-extract-plugin | Native equivalent |
|---|---|
filename | output.cssFilename |
chunkFilename | output.cssChunkFilename |
loader publicPath | output.publicPath |
loader esModule | generator esModule (default true) |
ignoreOrder | n/a — native CSS does not emit order-conflict warnings |
webpack.config.js
export default {
experiments: { css: true },
output: {
cssFilename: "[name].[contenthash].css",
cssChunkFilename: "[id].[contenthash].css",
},
};4. Replace css-loader
Most css-loader options have a native counterpart under module.parser.css and module.generator.css. The common defaults (url, import, namedExports all on) already match a typical css-loader config, so many projects need no parser config at all.
css-loader option | Native equivalent |
|---|---|
url | module.parser.css.url — default true |
import | module.parser.css.import — default true |
importLoaders | n/a — loaders in the chain apply to @imported files automatically |
sourceMap | controlled by devtool (supports a per-type css entry) |
esModule | module.generator.css.esModule — default true |
exportType: 'string' | parser exportType: "text" |
exportType: 'css-style-sheet' | parser exportType: "css-style-sheet" |
modules (auto-detect) | css/auto module type (built-in) |
modules.mode: 'local' | css/module type |
modules.mode: 'global' | css/global type |
modules.mode: 'pure' | parser pure: true |
modules.localIdentName | generator localIdentName |
modules.exportLocalsConvention | generator exportsConvention |
modules.namedExport | parser namedExports — default true |
modules.exportOnlyLocals | generator exportsOnly |
modules.localIdentHashSalt | generator localIdentHashSalt |
modules.localIdentHashFunction | generator localIdentHashFunction |
For example, this css-loader CSS Modules config:
export default {
module: {
rules: [
{
test: /\.module\.css$/i,
use: [
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:base64:6]",
exportLocalsConvention: "camel-case-only",
namedExport: true,
},
},
},
],
},
],
},
};becomes:
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
namedExports: true,
},
},
generator: {
"css/auto": {
localIdentName: "[local]-[hash:base64:6]",
exportsConvention: "camel-case-only",
},
},
},
};A few css-loader options work differently:
getLocalIdent— instead of a custom function, native CSS drives naming through thelocalIdentNametemplate, which also accepts a function.getJSON— the class-name mapping is exported by the CSS module itself and readable from the compilation's module graph, so a small plugin can serialize it to JSON when a framework needs the file on disk. For server-side rendering, you usually don't need it at all — see Server-side rendering.localIdentRegExpand filter-styleurl/importcallbacks have no native equivalent; keepcss-loaderfor the affected files, or exclude specific requests withIgnorePlugin.
5. Replace style-loader
If you used style-loader to inject styles at runtime instead of extracting a file, set exportType: "style":
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};This injects a <style> element from the webpack runtime, covering the default style-loader (injectType: "styleTag") use case. Scope it to a single rule if only some files should be injected while the rest are extracted:
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.inline\.css$/i,
type: "css/auto",
parser: { exportType: "style" },
},
],
},
};Notes on style-loader options: injectType: "linkTag" corresponds to the default exportType: "link" (extraction); attributes, insert, and styleTagTransform have no native equivalent — keep style-loader if you rely on them.
6. Keep using preprocessors (Sass, Less, PostCSS)
Native CSS replaces the CSS loaders, not preprocessor loaders. Keep the preprocessor loader in use and set the rule's type to css/auto so webpack treats the loader's output as CSS:
webpack.config.js
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ["postcss-loader", "sass-loader"],
type: "css/auto",
},
],
},
};sass-loader compiles to CSS, postcss-loader post-processes it, and native CSS handles extraction, url(), and CSS Modules from there. The same pattern works for less-loader, stylus-loader, and friends.
7. Server-side rendering (node + web)
For SSR you typically build twice — a web bundle for the browser and a node bundle for the server — and the CSS Modules class names must match so the server-rendered markup hydrates cleanly on the client. This is what css-loader's getJSON was often used to round-trip; with native CSS you avoid the round-trip entirely by making localIdentName deterministic across targets.
Use a path-based template (no compilation-wide hash) so the same class name is produced on every target:
webpack.config.js
const common = {
experiments: { css: true },
module: {
rules: [
{
test: /\.module\.css$/i,
type: "css/module",
generator: {
// `[file]__[local]` is stable across targets — no getJSON sync needed.
localIdentName: "[file]__[local]",
},
},
],
},
};
export default [
{ ...common, name: "web", target: "web" },
{ ...common, name: "node", target: "node" },
];On a node target the CSS generator defaults to exportsOnly: true, so the server build exports only the class-name mapping and emits no stylesheet — exactly what an SSR renderer needs. The browser build still extracts the real CSS. If you prefer a single config, target: ["web", "node"] builds a universal bundle that runs in both environments.
8. Keep imports unchanged and validate
Your JS imports stay the same:
import "./styles.css";
import * as styles from "./button.module.css";Then check that:
- styles apply correctly in development,
- extracted
.cssfiles are emitted in production, - CSS Modules exports match your existing usage.
All options with examples
Configure options per module type under module.parser and module.generator. The keys are css, css/auto, css/global, and css/module; the examples below use css/auto since it backs the default rule.
Parser options
All boolean parser options below default to true.
| Option | Type | Default | Description |
|---|---|---|---|
import | boolean | true | Handle @import at-rules. |
url | boolean | true | Handle url() / image-set() / src() / image(). |
namedExports | boolean | true | Export CSS Modules locals as ES module named exports. |
exportType | "link" | "style" | "text" | "css-style-sheet" | "link" | How the CSS is emitted (see Output modes). |
pure | boolean | false | Strict pure mode — every selector must contain a local class/id. css/module and css/auto only. |
as | "stylesheet" | "block-contents" | "stylesheet" | Parse the source as a full stylesheet or as a block's contents. |
animation | boolean | true | Rename local @keyframes names. |
container | boolean | true | Rename local @container names. |
customIdents | boolean | true | Rename custom identifiers. |
dashedIdents | boolean | true | Rename dashed identifiers (custom properties). |
function | boolean | true | Rename local @function names. |
grid | boolean | true | Rename grid line/area identifiers. |
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
import: true,
url: true,
namedExports: true,
exportType: "link",
pure: false,
// Only rename @keyframes; leave @container / grid identifiers untouched.
animation: true,
container: false,
grid: false,
},
},
},
};Generator options
| Option | Type | Default | Description |
|---|---|---|---|
localIdentName | string | function | "[uniqueName]-[id]-[local]" (dev) / "[fullhash]" (prod) | Template for generated local class names. |
exportsConvention | "as-is" | "camel-case" | "camel-case-only" | "dashes" | "dashes-only" | function | "as-is" | Naming convention for exported locals. |
exportsOnly | boolean | true on targets without a document (e.g. node), else false | Only export locals; skip emitting a stylesheet (SSR). |
esModule | boolean | true | Emit ES module syntax for the generated JS. |
localIdentHashFunction | string | output.hashFunction | Hash function for localIdentName hashes. |
localIdentHashDigest | string | "base64url" | Hash digest encoding for local idents. |
localIdentHashDigestLength | number | 6 | Hash digest length for local idents. |
localIdentHashSalt | string | output.hashSalt | Hash salt for local idents. |
export default {
experiments: { css: true },
module: {
generator: {
"css/auto": {
localIdentName: "[uniqueName]-[id]-[local]",
exportsConvention: "camel-case-only",
esModule: true,
exportsOnly: false,
localIdentHashDigest: "base64url",
localIdentHashDigestLength: 6,
},
},
},
};exportsConvention also accepts a function returning a string or string[] — returning an array exports the local under several aliases, matching css-loader's behavior.
Popular examples
CSS Modules with named exports
src/app.module.css
.primary {
color: #1f6feb;
}
.large-text {
font-size: 2rem;
}src/index.js
import { largeText, primary } from "./app.module.css";
document.body.classList.add(primary, largeText);webpack.config.js
export default {
experiments: { css: true },
module: {
generator: {
"css/auto": {
exportsConvention: "camel-case-only",
},
},
},
};Extract hashed CSS files for production
webpack.config.js
export default {
mode: "production",
experiments: { css: true },
output: {
cssFilename: "css/[name].[contenthash].css",
cssChunkFilename: "css/[id].[contenthash].css",
},
};Inject <style> tags at runtime (style-loader style)
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};Import a constructable stylesheet
src/index.js
import sheet from "./theme.css" with { type: "css" };
document.adoptedStyleSheets = [sheet];Webpack resolves the with { type: "css" } import assertion to exportType: "css-style-sheet" automatically, giving you a CSSStyleSheet instance.
Import CSS as a string
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "text",
},
},
},
};src/index.js
import css from "./styles.css";
const style = new CSSStyleSheet();
style.replaceSync(css);Global styles + scoped modules side by side
With the default css/auto rule, *.module.css is scoped and everything else is global — no extra config:
import "./reset.css"; // global
import * as card from "./card.module.css"; // scopedExperimental status & known limitations
experiments.css is explicitly experimental — treat it as opt-in and test carefully before a broad rollout.
- APIs and behavior may still evolve before webpack v6 defaults.
- A few loader options have no drop-in switch:
css-loader'slocalIdentRegExpand filter callbacks, andstyle-loader'sattributes/insert/styleTagTransform. Keep the loader for files that need them. (getLocalIdentmaps to thelocalIdentNamefunction form, andgetJSON/SSR is covered by matching class names across targets.) importLoadershas no equivalent — loaders in the chain apply to@imported files automatically.- If your project relies on advanced loader chains, validate each part before migrating fully.



