postcss-nested: unwrapping Sass style nesting, with the edge cases named
PostCSS plugin to unwrap nested rules like how Sass does it.
At a glance
- What is it?
- postcss-nested is a PostCSS plugin that turns nested rules into flat selectors the way Sass does, and its documentation is unusually explicit about the cases where it differs from Sass, from how at-rules bubble to when empty intermediate selectors are stripped. Version 9.0.0 is out, it is built by Evil Martians, and it depends on a single runtime package.
- Who is it for?
- postcss-nested fits a project that wants Sass style nesting without adopting Sass, particularly one already running PostCSS, and that is willing to learn the places where it differs from Sass. It is the wrong choice if you want the standard behaviour, since postcss-nesting implements the CSSWG draft and the README points there as the alternative, and the wrong choice if you need nested properties such as font-size, which is a separate plugin.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 2 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The input and the output, which is the whole specification
The README shows one input and one output, and they are worth reading side by side because everything else in the plugin is a variation on them.
The input:
.phone {
&_title {
width: 500px;
@media (max-width: 500px) {
width: auto;
}
body.is_dark & {
color: white;
}
}
img {
display: block;
}
}
.title {
font-size: var(--font);
@at-root html {
--font: 16px;
}
}And the output:
.phone_title {
width: 500px;
}
@media (max-width: 500px) {
.phone_title {
width: auto;
}
}
body.is_dark .phone_title {
color: white;
}
.phone img {
display: block;
}
.title {
font-size: var(--font);
}
html {
--font: 16px;
}Four distinct behaviours are visible there, and they are the four things to understand about this plugin.
The first is the ampersand. &_title becomes .phone_title, with the underscore joining the parent and the child name, which is the Sass convention for a BEM style class. The ampersand also works as a suffix, so body.is_dark & becomes body.is_dark .phone_title, and the parent is placed where the ampersand was rather than always in front.
The second is that a nested rule without an ampersand, the img case, becomes a descendant selector, .phone img.
The third is the at-rule inside a nested rule. The @media block moves out to the top level and the nested selector is repeated inside it, so the media query wraps a copy of the rule rather than the rule being flattened into a single selector. That is the behaviour that makes nesting composable with media queries at all.
The fourth is @at-root, which breaks the rule out of its nested position entirely. The @at-root html block becomes a top level html rule with the custom property declared on the root rather than inside .title, and that is the difference between a custom property being scoped and being global.
So the output is not a simple string concatenation of selectors. The plugin has to decide, for each nested construct, whether to bubble it, unwrap it or inline it, and the options are how you configure those decisions.
bubble and unwrap, the two lists that decide everything
The two options that matter most are bubble and unwrap, and they are the same mechanism applied to different at-rules.
By default, the plugin will bubble only @media, @supports, @layer, @container and @starting-style at-rules. You use the bubble option to add your custom at-rules to that list:
postcss([postcssNested({ bubble: ['phone'] })])With that option, a custom @phone rule inside a rule is lifted to the top level and the parent selector is repeated inside it:
/* input */
a {
color: white;
@phone {
color: black;
}
}
/* output */
a {
color: white;
}
@phone {
a {
color: black;
}
}That is the same shape as the @media case in the main example, and it is what a design system needs when it has its own at-rule for a breakpoint or a theme.
The unwrap option is the opposite. By default, the plugin will unwrap only @font-face, @keyframes and @document at-rules, and you can add your own with the unwrap option:
postcss([postcssNested({ unwrap: ['phone'] })])And the difference in the output is the key thing:
/* input */
a {
color: white;
@phone {
color: black;
}
}
/* output */
a {
color: white;
}
@phone {
color: black;
}With unwrap, the at-rule is lifted out and its contents are not re-wrapped in the parent selector. The declaration inside @phone applies to @phone itself rather than to a nested a. That is the correct treatment for at-rules like @font-face, where the content is a font descriptor and not a rule for the parent selector, and it is why @font-face and @keyframes are in the default unwrap list.
So the rule of thumb is: bubble for at-rules that contain ordinary rules, unwrap for at-rules whose contents are not rules. And the default lists are deliberately narrow, which is the thing to know before you start. If you write a custom at-rule and expect it to nest, it will not, until you add it to one of these two lists, and the failure will be silent because the output is still valid CSS, just not the CSS you meant.
preserveEmpty and rootRuleName, and the plugin boundaries
Two more options, one about a corner case and one about renaming a feature.
preserveEmpty defaults to false, and by default the plugin will strip out any empty selector generated by intermediate nesting levels. The example shows it:
.a {
.b {
color: black;
}
}compiles to:
.a {
}
.a .b {
color: black;
}The .a rule is still emitted, and it is empty. Setting preserveEmpty to true keeps the empty selectors. The README says this is especially useful if you want to export the empty classes with postcss-modules, and that makes the reason clear. A CSS modules tool that scans your stylesheets for class names needs to see .a to know that the class exists, and an empty rule that has been stripped is a class that has silently disappeared from the export. So the default is right for ordinary stylesheets and wrong for a modules workflow, and the option exists because the plugin cannot know which one you are in.
rootRuleName is about the @at-root at-rule, which the plugin supports as the SCSS custom at-rule that breaks rule blocks out of their nested position. If you want a different custom name for it in your code, you can set it:
postcss([postcssNested({ rootRuleName: '_escape-nesting' })])And then:
/* input */
.a {
color: white;
@_escape-nesting {
.b {
color: black;
}
}
}
/* output */
.a {
color: white;
}
.b {
color: black;
}The reason to rename it is a collision. @at-root is a name that another tool, or a future CSS feature, may want, and the underscore prefix convention puts it in the same namespace as a private helper. A project that already uses @at-root for something else can therefore use a different name here rather than coordinating with whoever owns the other one.
The README is also careful about the boundaries of this plugin, and the related plugins section is the most useful part of it for anyone deciding whether it fits.
Use postcss-current-selector after this plugin if you want to use the current selector in properties or variables values, so the ordering constraint is explicit. Use postcss-nested-ancestors before this plugin if you want to reference any ancestor element directly in your selectors with the caret ampersand syntax.
And the alternatives section points at postcss-nesting, which implements the CSSWG draft, plus postcss-nested-props for nested properties like font-size. That last one is the boundary of this plugin in a sentence: postcss-nested handles nested rules, not nested properties, and a project that wants font-size nested inside font needs a second plugin.
Why nesting needs a plugin at all, and where Sass and the CSS draft differ
The reason postcss-nested exists at all is that nesting in CSS is a different thing from nesting in Sass, and the differences are the design decisions.
Sass nesting was designed as a preprocessor convenience in a language with variables, mixins and control flow. Its output rules are the ones the plugin copies: the ampersand as a placeholder for the parent selector, descendant selectors for nested rules without one, and an at-root escape hatch for breaking out. The plugin's own description is to unwrap nested rules closer to Sass syntax, and the closeness is the point, because a team moving from Sass to PostCSS wants the same source to keep working.
The CSS nesting draft takes a different approach. It is designed for plain CSS, where the selector inside a nested rule is a relative selector, the nesting selector is the ampersand with a meaning closer to Sass's, and there is no at-root because the draft does not need one. The README points at postcss-nesting as the alternative that implements the CSSWG draft, and that link is the single most useful pointer in the document for anyone choosing between them.
The practical difference in daily use is what happens with a nested rule that has no ampersand and no type selector. Sass turns .a { .b { } } into .a .b. The CSS draft treats .b as a relative selector, and in newer revisions of the draft that is written as & .b to make the intent explicit. The Sass style is the more forgiving one, since it does what you meant without the ampersand, which is why it is the popular choice even though it is not the standard.
The at-rule handling is the other difference, and it is where the two approaches genuinely diverge in behaviour rather than in syntax. In the draft, at-rules are a specified part of the nesting model, with rules about which ones bubble and which ones are scoped. In Sass, at-rules are inherited from the era before CSS had nested rules, so @media has always bubbled and @font-face has always been special, and other at-rules follow rules of their own that Sass has accumulated over the years.
That is what the bubble and unwrap option lists are, in effect. They are a configuration of the Sass at-rule rules for the specific at-rules you use, because a modern design system has at-rules that Sass never saw. The default lists are narrow on purpose, so the behaviour is predictable, and the option is how you extend them.
One more thing the README is careful about, and it is a warning about ordering rather than about syntax. The current selector plugin goes after this one, and the ancestors plugin goes before it, and both orderings are load bearing. A plugin that needs to know the current selector has to run after the nesting has been resolved, and a plugin that introduces ancestor references has to run before the nesting is resolved, because after that point the ancestors are gone. That kind of statement in a README is a sign the author has debugged the interactions rather than just documented them.
The package, the toolchain and what the version numbers imply
The package.json is small and tells you what this project is in 2026.
The runtime dependency list has one entry, postcss-selector-parser at 7.1.6, and that is the whole dependency. Everything else is a peer dependency on postcss at 8.2.14 or newer, with postcss itself at 8.5.30 in the dev dependencies. A selector parser is exactly what a nesting plugin needs, since the whole job is taking a nested selector and computing what its flattened form should be, and doing that with a hand written string operation would be the classic source of bugs in this kind of plugin.
The engines field is the notable change. Node must be ^22.0.0 or ^24.0.0 or 26 or newer, so the two current release lines plus whatever comes next, and Node 20 is not supported. For a build tool that runs in a developer's npm install or in a CI container, that is a real constraint on projects still on Node 20, and it is worth checking before you upgrade the plugin.
The package is an ES module, with type set to module and the exports map pointing at index.js plus a package.json export. There is an index.d.ts at the repository root alongside index.js and index.test.js, so types ship with the package, and the type definitions are handwritten rather than generated.
The tooling has moved on from the PostCSS house style. The test script runs pnpm bnt with coverage at 100, and bnt is better-node-test, a Node test runner wrapper, so the tests use the Node built in test runner rather than Jest. The lint script is oxlint, with an oxlint-tsgolint plugin and the @logux/oxc-configs shared configuration, and there are oxlint.config.ts and oxfmt.config.ts files at the root for oxlint and oxfmt. So the project has moved from ESLint and Prettier to the Oxc family of tools, and the config lives in TypeScript files.
The TypeScript dev dependency is at 7.0.2, which is the TypeScript 7 line, and the peer dependency on postcss is separate from the dev dependency version, which is the correct pattern for a plugin.
The version history is where the API stability question gets answered. The recent releases are 9.0.0 on 2026-10-15, 8.0.1 on 2026-08-02 and 8.0.0 on 2026-07-29, so there was a major version in July and another in October, with the newest one published the same day as the last push. Two major versions in three months is a lot of churn, and it lines up with the Node engines requirement moving. If you adopt this, expect to read the changelog when you upgrade, and pin the version rather than tracking latest.
The authorship note is also relevant if you are evaluating the project. The plugin is built by Evil Martians, described as an American design and engineering consultancy for developer tools, AI and cybersecurity startups, and the package author is Andrey Sitnik, with the project funded through Open Collective and GitHub Sponsors. A plugin with a single runtime dependency, a 100 percent coverage requirement and a named consultancy behind it is a different risk profile from a personal plugin, and the 9.0.0 release on the day of the last push shows it is being actively maintained rather than merely maintained.
Editorial conclusion
postcss-nested fits a project that wants Sass style nesting without adopting Sass, particularly one already running PostCSS, and that is willing to learn the places where it differs from Sass. It is the wrong choice if you want the standard behaviour, since postcss-nesting implements the CSSWG draft and the README points there as the alternative, and the wrong choice if you need nested properties such as font-size, which is a separate plugin. Before adopting, read the bubble and unwrap options carefully, because the default lists of at-rules are narrow and a custom at-rule you expected to nest will not nest until you add it.
Frequently asked questions
How do I install and enable postcss-nested?
Run npm install --save-dev postcss postcss-nested, check whether your project already has a PostCSS config in postcss.config.js, a postcss section in package.json or a postcss entry in the bundle config, then add postcssNested to the plugins list, before autoprefixer if you use it.
What is the difference between the bubble and unwrap options in postcss-nested?
Both control how at-rules are lifted out of a nested position. By default the plugin bubbles only @media, @supports, @layer, @container and @starting-style, and unwraps only @font-face, @keyframes and @document. With bubble the parent selector is repeated inside the lifted at-rule, with unwrap the contents are left as they are.
What does postcss-nested do with empty selectors?
By default it strips out any empty selector generated by intermediate nesting levels, so a rule nested only for grouping is emitted empty. Setting preserveEmpty to true keeps them, which the README recommends if you want to export the empty classes with postcss-modules.
Does postcss-nested support nested properties like font-size?
No. The README lists postcss-nested-props as the plugin for nested properties such as font-size, and postcss-nesting as the alternative that implements the CSSWG draft rather than Sass style nesting.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/postcss-postcss-nested)Was this useful?