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:

TypeScopingcss-loader equivalent
cssGlobal, no CSS Modules parsingmodules: false
css/globalGlobal selectors, but :local() is honoredmodules.mode: 'global'
css/moduleLocal by default, :global() escapes to globalmodules.mode: 'local'
css/autoPicks css/module for *.module.css / *.modules.css, otherwise css/globalmodules.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 (including composes: 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:

exportTypeBehaviorReplaces
"link" (default)Extracts a .css file, loaded via <link>mini-css-extract-plugin
"style"Injects a <style> element from the runtimestyle-loader
"text"Exports the CSS as a stringcss-loader exportType: 'string'
"css-style-sheet"Exports a constructable CSSStyleSheetcss-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 setupNative equivalent
mini-css-extract-plugin (MiniCssExtractPlugin.loader)built-in extraction (default exportType: "link")
MiniCssExtractPlugin filename / chunkFilenameoutput.cssFilename / output.cssChunkFilename
style-loaderexportType: "style"
css-loaderbuilt-in CSS parsing (no loader needed)
css-loader url / importmodule.parser.css.url / import (both default true)
css-loader modules (.module.css auto-detect)css/auto module type
css-loader modules.modecss/module / css/global type + pure
css-loader modules.localIdentNamegenerator localIdentName
css-loader modules.exportLocalsConventiongenerator exportsConvention
css-loader modules.namedExportmodule.parser.css.namedExports (default true)
css-loader modules.exportOnlyLocalsgenerator exportsOnly
css-loader esModulegenerator 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$/icss/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-pluginNative equivalent
filenameoutput.cssFilename
chunkFilenameoutput.cssChunkFilename
loader publicPathoutput.publicPath
loader esModulegenerator esModule (default true)
ignoreOrdern/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 optionNative equivalent
urlmodule.parser.css.url — default true
importmodule.parser.css.import — default true
importLoadersn/a — loaders in the chain apply to @imported files automatically
sourceMapcontrolled by devtool (supports a per-type css entry)
esModulemodule.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.localIdentNamegenerator localIdentName
modules.exportLocalsConventiongenerator exportsConvention
modules.namedExportparser namedExports — default true
modules.exportOnlyLocalsgenerator exportsOnly
modules.localIdentHashSaltgenerator localIdentHashSalt
modules.localIdentHashFunctiongenerator 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 the localIdentName template, 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.
  • localIdentRegExp and filter-style url/import callbacks have no native equivalent; keep css-loader for the affected files, or exclude specific requests with IgnorePlugin.

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 .css files 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.

OptionTypeDefaultDescription
importbooleantrueHandle @import at-rules.
urlbooleantrueHandle url() / image-set() / src() / image().
namedExportsbooleantrueExport CSS Modules locals as ES module named exports.
exportType"link" | "style" | "text" | "css-style-sheet""link"How the CSS is emitted (see Output modes).
purebooleanfalseStrict 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.
animationbooleantrueRename local @keyframes names.
containerbooleantrueRename local @container names.
customIdentsbooleantrueRename custom identifiers.
dashedIdentsbooleantrueRename dashed identifiers (custom properties).
functionbooleantrueRename local @function names.
gridbooleantrueRename 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

OptionTypeDefaultDescription
localIdentNamestring | 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.
exportsOnlybooleantrue on targets without a document (e.g. node), else falseOnly export locals; skip emitting a stylesheet (SSR).
esModulebooleantrueEmit ES module syntax for the generated JS.
localIdentHashFunctionstringoutput.hashFunctionHash function for localIdentName hashes.
localIdentHashDigeststring"base64url"Hash digest encoding for local idents.
localIdentHashDigestLengthnumber6Hash digest length for local idents.
localIdentHashSaltstringoutput.hashSaltHash 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"; // scoped

Experimental 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's localIdentRegExp and filter callbacks, and style-loader's attributes / insert / styleTagTransform. Keep the loader for files that need them. (getLocalIdent maps to the localIdentName function form, and getJSON/SSR is covered by matching class names across targets.)
  • importLoaders has 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.
Edit this page·

1 Contributor

phoekerson