PostCSS plugin to unwrap nested rules like how Sass does it
npx @tessl/cli install tessl/npm-postcss-nested@7.0.0PostCSS Nested is a PostCSS plugin that unwraps nested CSS rules similar to Sass syntax. It enables developers to write nested CSS with parent selector references (&), automatic selector merging, at-rule bubbling, and custom root rule handling for breaking out of nesting contexts.
npm install postcss-nestedconst postcssNested = require('postcss-nested');ESM:
import postcssNested from 'postcss-nested';const postcss = require('postcss');
const postcssNested = require('postcss-nested');
// Basic usage with default options
const result = postcss([postcssNested()])
.process(css, { from: 'input.css' });
// With custom options
const result = postcss([
postcssNested({
bubble: ['phone'],
preserveEmpty: true,
rootRuleName: 'escape-nesting'
})
]).process(css, { from: 'input.css' });Input CSS:
.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;
}
}Output CSS:
.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;
}Creates a PostCSS plugin instance with optional configuration.
/**
* Creates a PostCSS plugin for processing nested CSS rules
* @param {Options} opts - Optional configuration object
* @returns {PostCSSPlugin} PostCSS plugin object
*/
function postcssNested(opts = {}) {
// Returns PostCSS plugin
}
// PostCSS compatibility flag
postcssNested.postcss = true;All configuration options are optional and have sensible defaults.
interface Options {
/**
* Custom at-rules that should bubble to the top level.
* Default: ['media', 'supports', 'layer', 'container', 'starting-style']
*/
bubble?: string[];
/**
* Custom at-rules that should be unwrapped from nested contexts.
* Default: ['document', 'font-face', 'keyframes', '-webkit-keyframes', '-moz-keyframes']
*/
unwrap?: string[];
/**
* Whether to preserve empty selector rules after unwrapping.
* Useful for CSS modules compatibility.
* Default: false
*/
preserveEmpty?: boolean;
/**
* Custom name for the at-root directive for breaking out of nesting.
* Default: 'at-root'
*/
rootRuleName?: string;
}The plugin returns a PostCSS plugin object with the required interface.
interface PostCSSPlugin {
/** Plugin identifier for PostCSS */
postcssPlugin: 'postcss-nested';
/** Initial processing of at-root rules */
Once(root: Root): void;
/** Main rule processing function */
Rule(rule: Rule): void;
/** Final cleanup of at-root rules */
RootExit(root: Root): void;
}Write CSS rules inside other rules, similar to Sass:
/* Input */
.card {
padding: 10px;
.title {
font-size: 18px;
}
.content {
margin-top: 10px;
}
}
/* Output */
.card {
padding: 10px;
}
.card .title {
font-size: 18px;
}
.card .content {
margin-top: 10px;
}Use & to reference the parent selector:
/* Input */
.button {
color: blue;
&:hover {
color: red;
}
&.active {
font-weight: bold;
}
.dark-theme & {
color: white;
}
}
/* Output */
.button {
color: blue;
}
.button:hover {
color: red;
}
.button.active {
font-weight: bold;
}
.dark-theme .button {
color: white;
}Media queries and other at-rules bubble to the root level:
/* Input */
.sidebar {
width: 300px;
@media (max-width: 768px) {
width: 100%;
.menu {
display: none;
}
}
}
/* Output */
.sidebar {
width: 300px;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
}
.sidebar .menu {
display: none;
}
}Certain at-rules are unwrapped and flattened:
/* Input */
.component {
color: black;
@keyframes slide {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
}
/* Output */
.component {
color: black;
}
@keyframes slide {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}Break out of nested contexts using @at-root:
/* Input */
.page {
color: black;
.content {
padding: 20px;
@at-root {
.modal {
position: fixed;
top: 50%;
left: 50%;
}
}
}
}
/* Output */
.page {
color: black;
}
.page .content {
padding: 20px;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
}Use @at-root with a selector for targeted escaping:
/* Input */
.theme {
.component {
color: blue;
@at-root html {
--primary-color: blue;
}
}
}
/* Output */
.theme .component {
color: blue;
}
html {
--primary-color: blue;
}Control which rules escape using with and without filters:
/* Input */
@media screen {
.component {
color: black;
@at-root (without: media) {
.global {
position: fixed;
}
}
}
}
/* Output */
@media screen {
.component {
color: black;
}
}
.global {
position: fixed;
}Add custom at-rules to bubble to the root:
postcss([
postcssNested({
bubble: ['custom-query', 'my-rule']
})
])/* Input */
.element {
color: black;
@custom-query (min-width: 600px) {
color: blue;
}
}
/* Output */
.element {
color: black;
}
@custom-query (min-width: 600px) {
.element {
color: blue;
}
}Add custom at-rules to unwrap:
postcss([
postcssNested({
unwrap: ['custom-keyframes']
})
])Keep empty parent rules for CSS modules:
postcss([
postcssNested({
preserveEmpty: true
})
])/* Input */
.parent {
.child {
color: red;
}
}
/* Output with preserveEmpty: true */
.parent {
}
.parent .child {
color: red;
}
/* Output with preserveEmpty: false (default) */
.parent .child {
color: red;
}Use a custom name for the at-root directive:
postcss([
postcssNested({
rootRuleName: 'escape'
})
])/* Input */
.nested {
color: black;
@escape {
.global {
position: fixed;
}
}
}
/* Output */
.nested {
color: black;
}
.global {
position: fixed;
}The plugin provides helpful error messages for common issues: