When a 15-Line PostCSS Plugin Beats Another Dependency
PostCSS 8's visitor API is small enough that a focused custom plugin often beats adopting a stale dependency. Here's a worked example, the ordering bug you'll actually hit, and when not to DIY.
08 Mar 2026, 20:22 UTC

Your build pipeline needs one small CSS transformation — say, duplicating every custom property declaration as a fallback, or rewriting a legacy unit. The instinct is to search npm for a plugin that does it. Sometimes that works. Often you find a package last published four years ago, pinned to PostCSS 7, with an open issue titled "broken in PostCSS 8." This is the moment where writing your own plugin is not a heroic act — it's the cheaper option.
The thesis: PostCSS's visitor API is small enough that a focused, single-purpose plugin is often less risky than adopting an unmaintained dependency, and it slots into the same config file you're already using.
What PostCSS actually gives you
PostCSS by itself does nothing. It parses CSS into an abstract syntax tree (AST) and hands that tree to a chain of plugins, each of which can inspect and mutate it. The value isn't the parser — it's the uniform pipeline. Autoprefixer, postcss-preset-env, and cssnano are all just plugins reading and writing the same tree.
In PostCSS 8 (the current major line), a plugin is a plain object with a postcssPlugin name and visitor methods. Instead of walking the tree yourself, you register listeners like Declaration(decl) or Rule(rule), and PostCSS calls them during a single traversal shared across all plugins. That design is why adding one more tiny plugin costs almost nothing at build time.
A worked example: a fallback-duplicating plugin
Suppose your team needs every declaration using a hypothetical inset-inline-style custom property to also emit a fallback. Save this as postcss-fallback.js in your project root:
module.exports = () => ({
postcssPlugin: 'postcss-fallback',
Declaration(decl) {
if (decl.prop.startsWith('--app-')) {
decl.cloneBefore({
prop: decl.prop.replace('--app-', '--legacy-'),
value: decl.value,
});
}
},
});
module.exports.postcss = true;Wire it into postcss.config.js:
module.exports = {
plugins: [
require('./postcss-fallback.js'),
require('autoprefixer'),
],
};To verify it, run the CLI from the project root (install it first with npm install --save-dev postcss postcss-cli, which requires only normal user permissions):
npx postcss src/app.css -o out/app.cssThen diff out/app.css against the input and confirm each --app-* declaration now has a --legacy-* sibling above it. The module.exports.postcss = true line matters: it tells PostCSS 8 that the factory returns a plugin object, and forgetting it produces a confusing loader error. Don't take my word for the output — run it on a scratch file and check.
Ordering is the bug you'll actually hit
PostCSS runs plugins sequentially over the same AST, in the order they appear in the config. That makes order a semantic decision, not a cosmetic one. The classic failure: a plugin that reads declarations runs before a plugin that generates them, so the generated declarations never get processed. This is why the convention is to put autoprefixer (and minifiers like cssnano) last — they should see the final tree.
If output ever looks like a plugin "didn't run," reorder before you debug. A quick experiment worth doing once: swap two plugins in your config, rebuild, and diff the output CSS. Seeing the difference firsthand makes the ordering rule stick better than any docs page.
Custom plugin vs. off-the-shelf: the honest trade-off
Writing your own plugin is the right call when the transformation is small, specific to your codebase, and unlikely to be maintained well by anyone else. You get a few dozen lines you can read, audit, and unit-test with PostCSS's own parse/stringify round-trip.
It's the wrong call when the problem is genuinely complex. Autoprefixer encodes years of browser data; cssnano encodes dozens of safe minification rules. Reimplementing those is how you ship subtly broken CSS. The skill is recognizing the boundary: a handful of if statements over declarations — write it; anything involving compatibility tables or spec edge cases — adopt it.
Three limitations to keep in mind:
- Version mismatch. PostCSS 7 and 8 plugin APIs differ, and plugins written for one may fail under the other. Check
npm ls postcssand confirm any plugin you adopt declares compatibility with your major version. - Non-standard syntax. The default parser handles plain CSS. If your source is SCSS or Less, you need a custom parser such as
postcss-scss, or your plugin will choke on nesting and variables. - Source maps. Some integrations don't enable them by default. Debugging transformed CSS without maps is miserable, so turn them on in your loader or CLI flags (
--mapfor postcss-cli) before you need them.
Where this fits in your build
The portability is the quiet win. webpack's postcss-loader, Vite's built-in PostCSS support, and postcss-cli all read the same postcss.config.js. A custom plugin you write today survives a build-tool migration tomorrow, because it's just a function in the plugin array.
So the actionable version: next time you reach for a CSS transform dependency, check its last publish date and its PostCSS major-version support first. If either looks stale and the transformation fits in one visitor method, write the plugin yourself, verify it with a CLI run and a diff, and keep autoprefixer last.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.