Lightning CSS vs PostCSS: Ditch the Plugin Bloat and Process CSS at Native Speed

PostCSS has been carrying the CSS ecosystem on its back since 2013. Autoprefixer, postcss-preset-env, postcss-nesting, cssnano — the plugin list on a real project can hit 10–15 packages before breakfast. Every one of those is JavaScript. Every one adds startup overhead, inter-plugin transformation passes, and a node_modules directory that your du -sh output will silently judge you for.

Lightning CSS changes the math. It’s a CSS parser, transformer, and minifier written in Rust that ships as a single binary and a Node.js native addon. The benchmark headline is usually "100x faster than PostCSS" — which is true in microbenchmarks but not the whole story. The real story is architectural: most of what you’ve been stitching together with plugins ships out of the box, and the things that don’t have a clear migration path.

This article is about making that switch without shooting yourself in the foot. Official repo: github.com/parcel-bundler/lightningcss.


Why PostCSS Is Starting to Show Its Age

PostCSS itself is not the problem — it’s a brilliant piece of engineering. The problem is the ecosystem it spawned. You’re running JavaScript code that reads CSS, turns it into an AST, passes it through N plugin functions in sequence, then serializes the AST back to a string. Each plugin can modify the AST, which means subsequent plugins re-walk a potentially mutated tree.

For small projects this is invisible. For a design system with 40k lines of CSS running on a watch build, it starts to hurt. The cascade of plugin versions, peer dependency conflicts between postcss-preset-env and autoprefixer, and subtle ordering issues (nesting must run before custom media queries, which must run before vendor prefixing — or was it the other way?) are a maintenance tax you pay every time you upgrade Node or your bundler.

Lightning CSS collapses that stack into one compiled Rust executable that processes CSS in a single pass. There’s no plugin chain. The transformations happen together, the AST is traversed once, and the output is emitted.


What Lightning CSS Does Out of the Box

Before comparing, you need to know what you’re actually getting. Lightning CSS handles:

  • Syntax lowering: draft CSS features (nesting, :is(), :has(), logical properties, color functions like oklch(), color-mix()) down to whatever browser targets you specify via a browserslist query
  • Vendor prefixing: same as Autoprefixer, driven by caniuse data baked into the binary
  • CSS Modules: scoping, composition, and composes: — far faster than css-modules via PostCSS
  • Minification: comparable to cssnano, sometimes better
  • Bundling: @import inlining
  • Source maps: accurate, fast

What it deliberately does not do:

  • Arbitrary plugin-based transformations — there’s no plugin API yet (as of v1.x)
  • Sass/Less/Stylus — it only handles standard CSS
  • Custom at-rules outside the CSS specification

If your PostCSS pipeline is basically postcss-preset-env + Autoprefixer + cssnano, you can drop all three and move to Lightning CSS today. If you’re running postcss-import, same story. If you’re using custom plugins that, say, inline SVG assets or implement a design token preprocessor, you’ll need a plan for those.


Performance: The Real Numbers

Don’t trust vendor benchmarks. Here’s what matters in practice.

On a design system with ~8,000 lines of source CSS, a cold PostCSS run (with postcss-preset-env, autoprefixer, cssnano) takes roughly 280–350ms. Lightning CSS on the same input: 4–8ms. That’s not a rounding error.

In watch mode the gap closes slightly because PostCSS benefits from module caching, but Lightning CSS is still 10–30x faster on incremental rebuilds. On a full production build this translates to seconds shaved off CI pipelines — not milliseconds.

The memory footprint is also dramatically smaller. PostCSS spins up a Node process, loads its plugins, and JIT-compiles them. Lightning CSS’s Node binding calls into native code directly. On large monorepos this matters for RAM-constrained CI runners.


Setting Up Lightning CSS Standalone

Install the CLI and the Node API:

npm install --save-dev lightningcss lightningcss-cli
# or
pnpm add -D lightningcss lightningcss-cli

The CLI is the fastest way to validate that your CSS survives the transformation:

npx lightningcss \
  --targets '>= 0.25%, last 2 versions, not dead' \
  --bundle \
  --minify \
  --source-map \
  --output-file dist/styles.css \
  src/styles.css

If that produces the output you expect, you’re 80% done with the migration.


Vite Integration

Vite 4.4+ ships with native Lightning CSS support. No extra plugin needed — it’s a first-class option in vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  css: {
    transformer: 'lightningcss',
    lightningcss: {
      // browserslist targets
      targets: {
        chrome: 95 << 16, // Chrome 95+
        firefox: 90 << 16,
        safari: (15 << 16) | (4 << 8),
      },
      // draft syntax to enable
      drafts: {
        customMedia: true,
      },
      // CSS Modules config (if you use them)
      cssModules: {
        pattern: '[hash]_[local]',
      },
    },
  },
  build: {
    cssMinify: 'lightningcss', // replace esbuild's CSS minifier too
  },
})

The targets field uses a packed version format — major << 16 | minor << 8 | patch. It’s ugly but it’s intentional: these map directly to the binary’s internal browser version table. In practice you’ll want to use the browserslistToTargets helper instead of hardcoding:

import { defineConfig } from 'vite'
import browserslist from 'browserslist'
import { browserslistToTargets } from 'lightningcss'

const targets = browserslistToTargets(
  browserslist('>= 0.5%, last 2 versions, not dead')
)

export default defineConfig({
  css: {
    transformer: 'lightningcss',
    lightningcss: { targets },
  },
})

Remove postcss.config.js and postcss.config.cjs from your repo root. Vite will stop looking for PostCSS config once transformer: 'lightningcss' is set.


Webpack Integration

Webpack users need the lightningcss-loader package:

npm install --save-dev lightningcss-loader
// webpack.config.js
const browserslist = require('browserslist')
const { browserslistToTargets } = require('lightningcss')

const targets = browserslistToTargets(
  browserslist('>= 0.5%, last 2 versions, not dead')
)

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader',
          {
            loader: 'css-loader',
            options: { importLoaders: 1 },
          },
          {
            loader: 'lightningcss-loader',
            options: {
              targets,
              // enable CSS nesting (stable in modern browsers,
              // but lowered for older targets)
              drafts: { nesting: true },
            },
          },
        ],
      },
    ],
  },
}

Replace the old chain that had postcss-loader with lightningcss-loader in the same position. The loader handles @import inlining, so you can drop postcss-import from the plugin list as well.


Node API for Build Scripts

If you have a custom build script that calls PostCSS programmatically, here’s the equivalent pattern with Lightning CSS:

// Before (PostCSS)
const postcss = require('postcss')
const autoprefixer = require('autoprefixer')
const presetEnv = require('postcss-preset-env')
const cssnano = require('cssnano')
const fs = require('fs')

const css = fs.readFileSync('src/styles.css', 'utf8')
const result = await postcss([
  presetEnv({ stage: 2 }),
  autoprefixer(),
  cssnano(),
]).process(css, { from: 'src/styles.css', map: { inline: false } })

fs.writeFileSync('dist/styles.css', result.css)
fs.writeFileSync('dist/styles.css.map', result.map.toString())
// After (Lightning CSS)
const { transform } = require('lightningcss')
const browserslist = require('browserslist')
const { browserslistToTargets } = require('lightningcss')
const fs = require('fs')

const targets = browserslistToTargets(
  browserslist('>= 0.5%, last 2 versions, not dead')
)
const source = fs.readFileSync('src/styles.css')

const { code, map } = transform({
  filename: 'src/styles.css',
  code: source,
  minify: true,
  sourceMap: true,
  targets,
  // lower draft syntax for older targets
  drafts: { nesting: true, customMedia: true },
})

fs.writeFileSync('dist/styles.css', code)
fs.writeFileSync('dist/styles.css.map', map)

Note that code is a Uint8Array, not a string. Write it directly with fs.writeFileSync — Node handles the buffer automatically.


Handling Custom PostCSS Plugins

This is where the migration gets honest. If you have custom PostCSS plugins, you have three options:

Option 1: Rewrite in vanilla CSS. If your plugin is transforming non-standard syntax because browsers didn’t support the feature yet, check if Lightning CSS handles it natively. Custom properties, env(), oklch(), nesting, :is() — these are all built in. The plugin may be obsolete.

Option 2: Pre-process with PostCSS, then hand off to Lightning CSS. This is the hybrid approach. Run your custom plugin first via PostCSS, output standard CSS, then feed that output into Lightning CSS for the rest. You lose some performance but maintain functionality:

// build.js — hybrid pipeline
const postcss = require('postcss')
const myTokenPlugin = require('./plugins/design-tokens')
const { transform } = require('lightningcss')

// Stage 1: custom transformations
const intermediate = await postcss([myTokenPlugin]).process(
  fs.readFileSync('src/styles.css', 'utf8'),
  { from: 'src/styles.css' }
)

// Stage 2: standard CSS → Lightning CSS handles the rest
const { code } = transform({
  filename: 'src/styles.css',
  code: Buffer.from(intermediate.css),
  minify: true,
  targets,
})

Option 3: Keep PostCSS for affected files only. In a monorepo, you might have one package that relies on a PostCSS plugin and everything else that doesn’t. Configure Lightning CSS at the bundler level and add a PostCSS step only for the specific package.


Gotchas

postcss-nested vs CSS nesting spec. The PostCSS plugin postcss-nested implements the pre-spec nesting behavior where & is optional and implicit. The CSS Nesting specification (what Lightning CSS implements) requires & explicitly or uses @nest for some cases. If you’ve been writing:

.card {
  .title { color: red; }
}

That works in Lightning CSS when targeting modern browsers. But if you have patterns like:

.parent {
  + .sibling { margin-top: 0; }
}

You need to update those to use &:

.parent {
  & + .sibling { margin-top: 0; }
}

Run a search for nested selectors that don’t start with &, :, ::, or a combinator — those are the ones that’ll silently produce wrong output if your plugin was more lenient than the spec.

Source maps and @import. Lightning CSS’s @import bundling is different from postcss-import. PostCSS import generates source maps that trace back through the import chain. Lightning CSS does the same, but if you’re using file-based @import conditions like @import "theme.css" supports(display: grid), make sure your targets actually need that lowering — Lightning CSS may strip the condition if all targets support it natively.

css-loader + Lightning CSS in webpack. When using css-loader with importLoaders: 1, the @import in CSS files is handled by webpack’s resolver, not Lightning CSS’s bundler. This means Lightning CSS doesn’t see the full merged file — it only processes each file independently. If you’re relying on cross-file custom property references working after bundling, test carefully. Usually it’s fine because custom properties are inherited at runtime, not compile time.

CSS Modules naming conflicts. If you’re migrating from postcss-modules to Lightning CSS’s built-in CSS Modules, the generated class name format differs. Update your pattern config to match the old format, or do a coordinated deploy so cached HTML with old class names doesn’t break.

postcss-preset-env features not in Lightning CSS. postcss-preset-env includes transforms for some proposal-stage features that Lightning CSS hasn’t implemented yet — things like @when/@else conditional rules, or certain Houdini properties. Check the Lightning CSS feature status before assuming full parity.


Production-Ready Setup

For a real production Vite project, here’s a complete baseline configuration that handles the common edge cases:

// vite.config.ts
import { defineConfig } from 'vite'
import browserslist from 'browserslist'
import { browserslistToTargets } from 'lightningcss'

// read browserslist config from package.json or .browserslistrc
const targets = browserslistToTargets(browserslist())

export default defineConfig({
  css: {
    transformer: 'lightningcss',
    lightningcss: {
      targets,
      drafts: {
        // enable only what you actually use
        nesting: true,
        customMedia: true,
      },
      pseudoClasses: {
        // map :focus-visible to :focus for older browsers if needed
        // focusVisible: 'focus',
      },
      cssModules: {
        // stable hashed names — important for caching
        pattern: '[hash]_[local]',
        // compose across files
        dashedIdents: false,
      },
    },
  },
  build: {
    cssMinify: 'lightningcss',
    // split CSS per chunk for better caching
    cssCodeSplit: true,
  },
})
// package.json — browserslist config
{
  "browserslist": [
    ">= 0.5%",
    "last 2 versions",
    "not dead",
    "not IE 11"
  ]
}

Don’t hardcode browser versions in vite.config.ts. Keeping browserslist in package.json lets tools like npx update-browserslist-db keep the caniuse data fresh, and it’s shared with any other tool in the project that respects browserslist.


Is It Worth Migrating?

For greenfield projects: use Lightning CSS from day one. The PostCSS plugin ecosystem was built to paper over gaps in browser support and CSS spec immaturity. Those gaps are mostly closed now.

For existing projects: the ROI depends on your current PostCSS config. If it’s the standard "preset-env + autoprefixer + cssnano" stack, the migration is an afternoon of work and the payoff is permanent — faster builds, fewer dependencies, fewer CVEs in your supply chain, no more plugin version conflicts. If you have a custom plugin doing something domain-specific, budget a day and plan the hybrid approach described above.

The one reason to stay on PostCSS for now: if you need to target IE 11 or Samsung Internet 4. Lightning CSS’s lowering targets are calibrated against modern browser ranges. Old-browser transforms are possible but require more deliberate configuration and you’ll hit edge cases faster.

Otherwise, the era of stringing together 12 PostCSS plugins to get production-ready CSS is over. One Rust binary does it better, faster, and with fewer moving parts — which is exactly what a build tool should be.

Leave a comment

👁 Views: 6,696 · Unique visitors: 10,667