PostCSS preset-env: polyfilling modern CSS features without ejecting your build
PostCSS preset-env polyfills :has() nesting and other future CSS into safe, browser-compatible output, but configuration and browser testing are essential for reliable results.
25 Mar 2026, 06:56 UTC

The problem with modern CSS today
You’re writing CSS that uses features like :has(), custom properties, or nested rules, and it works in your editor but breaks in production browsers without a transpile step. Traditional preprocessors such as Sass provide nesting natively, but they don’t polyfill CSS-level selectors, and including a build step just to use one selector can feel like overkill. In environments that skip a full bundler, you’re left either without the feature or hacking JavaScript polyfills that don’t always match the spec.
How preset-env polyfills modern CSS features
PostCSS preset-env is a plugin that transforms CSS custom properties, at-rules, and selectors into safe, browser-compatible output at build time. It parses your CSS, checks each feature against the browser list defined in your project’s browserslist, and either keeps the rule as-is when the target browser supports it or rewrites it into an equivalent @supports query or a broader-compatible pattern. The transformation happens once, producing static CSS that works everywhere your targets reach, or falls back gracefully.
Key terms: @supports is a CSS at-rule that queries whether a browser supports a given property or selector. If a browser doesn’t support :has(), preset-env can rewrite the rule so it doesn’t rely on the unsupported selector, or it can leave it untouched when your browserslist already includes modern versions.
Migration example: converting Sass nesting to native CSS with preset-env
Many projects use Sass for its nesting syntax. With preset-env you can write standard CSS nesting and let the plugin handle fallbacks. Consider this Sass snippet:
.card {
background: white;
border: 1px solid currentColor;
img {
border-radius: 4px;
}
&:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
}
In native CSS nesting (supported in modern browsers), you would write:
.card {
background: white;
border: 1px solid currentColor;
& img {
border-radius: 4px;
}
&:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
}
Running this through PostCSS with postcss-preset-env configured for your target browsers will transform the & nesting selector into a flat selector list (e.g., .card img, .card:hover) for browsers that don’t support native nesting. The exact output depends on the features or stage option you pass to the plugin. Enabling the nesting feature tells preset-env to handle nesting transformations, while leaving it on produces native nesting when your browser list includes modern versions.
Configuration, trade-offs, and verification
Adding preset-env to your PostCSS pipeline is straightforward. A minimal postcss.config.js looks like this:
module.exports = {
plugins: [
require('postcss-preset-env')({
stage: 4,
features: {
// selectively enable only what you need
nesting: true,
'custom-properties': true,
'has-selector': true
}
})
]
};
The stage option is a shorthand for a set of features. stage: 4 enables most stable CSS features, but it also enables everything below it. Many teams prefer listing individual features to keep the compiled CSS lean.
| Feature | preset-env behavior |
|---|---|
:has() | Transformed or preserved based on browser support defined in browserslist |
| Nesting | Handled by postcss-nesting or native if stage includes it |
| Custom properties | Polyfilled with static values where needed |
Trade-off: Every enabled feature adds transformation logic. If you enable broad features without matching browser targets, your compiled CSS can grow in size, and build time ticks up slightly. Disabling features you don’t use keeps the output tight. Also, not all CSS features are fully polyfilled; some newer selectors may only receive a @supports guard rather than a full rewrite.
Verification in practice: After adding the plugin, run your project’s normal build and inspect the compiled CSS. Compare the output to your input: are nesting rules flattened, custom properties inlined, :has() rules preserved or converted? Use caniuse.com to confirm which features are natively supported in the browser versions your project targets. If a transformation looks unexpected, narrow the features list or adjust your browserslist query.
Command example: In your project root (requires Node.js and write permissions for package.json), install the plugin:
npm install -D postcss-preset-env
Where to run: Project root directory, alongside your other PostCSS configuration.
Required permissions: Write access to install dependencies; no runtime privileges needed.
Meaningful placeholder: Adjust browserslist in package.json (e.g., "browserslist": ["> 0.5%", "not dead"]) to match your target audience.
Expected checks: After building, compare the compiled CSS output to your source; verify that nesting is flattened only for unsupported browsers.
Relevant risks: Enabling too many features can inflate CSS size and slow builds; failing to align browserslist may produce unexpected fallbacks or broken layouts.
Use preset-env when you want to write cutting-edge CSS syntax today and have it work in production browsers without a runtime polyfill. If your project already relies on a heavy build pipeline, or you only need a few modern features, individual PostCSS plugins such as postcss-nesting for nesting or postcss-custom-properties for variables might be lighter weight. Always validate the compiled output against your target browsers before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.