PostCSS's plugin system allows developers to create transformations that process CSS AST nodes. This document covers the modern plugin API, legacy plugin support, and plugin development patterns.
Modern PostCSS plugins implement the Plugin interface with lifecycle hooks for different node types.
import { Plugin, Processors, Result, Root, Document, Rule, AtRule, Declaration, Comment } from 'postcss'
interface Plugin extends Processors {
postcssPlugin: string
prepare?(result: Result): Processors
}
interface PluginCreator<PluginOptions> {
(opts?: PluginOptions): Plugin | Processor
postcss: true
}// Modern plugin API
const myPlugin = (opts = {}) => {
return {
postcssPlugin: 'my-plugin',
// Plugin lifecycle hooks go here
}
}
myPlugin.postcss = true
// With TypeScript
interface MyPluginOptions {
prefix?: string
exclude?: string[]
}
const myTypedPlugin: PluginCreator<MyPluginOptions> = (opts = {}) => {
const { prefix = 'auto-', exclude = [] } = opts
return {
postcssPlugin: 'my-typed-plugin',
Rule(rule, { result }) {
if (!exclude.includes(rule.selector)) {
rule.selector = `${prefix}${rule.selector}`
}
}
}
}
myTypedPlugin.postcss = trueThe Processors interface defines lifecycle hooks that plugins can implement:
interface Processors {
Once?: RootProcessor
OnceExit?: RootProcessor
Root?: RootProcessor
RootExit?: RootProcessor
Document?: DocumentProcessor
DocumentExit?: DocumentProcessor
Rule?: RuleProcessor
RuleExit?: RuleProcessor
AtRule?: { [name: string]: AtRuleProcessor } | AtRuleProcessor
AtRuleExit?: { [name: string]: AtRuleProcessor } | AtRuleProcessor
Declaration?: { [prop: string]: DeclarationProcessor } | DeclarationProcessor
DeclarationExit?: { [prop: string]: DeclarationProcessor } | DeclarationProcessor
Comment?: CommentProcessor
CommentExit?: CommentProcessor
}import { postcss } from 'postcss'
type RootProcessor = (root: Root, helper: postcss.Helpers) => Promise<void> | void
type DocumentProcessor = (document: Document, helper: postcss.Helpers) => Promise<void> | void
type RuleProcessor = (rule: Rule, helper: postcss.Helpers) => Promise<void> | void
type AtRuleProcessor = (atRule: AtRule, helper: postcss.Helpers) => Promise<void> | void
type DeclarationProcessor = (decl: Declaration, helper: postcss.Helpers) => Promise<void> | void
type CommentProcessor = (comment: Comment, helper: postcss.Helpers) => Promise<void> | void
interface Helpers {
result: Result
postcss: typeof postcss
}Execute once per processing run, regardless of AST changes:
const setupPlugin = () => ({
postcssPlugin: 'setup-plugin',
Once(root, { result }) {
// Run once at the start
result.messages.push({
type: 'setup',
plugin: 'setup-plugin',
message: 'Processing started'
})
},
OnceExit(root, { result }) {
// Run once at the end
result.messages.push({
type: 'cleanup',
plugin: 'setup-plugin',
message: 'Processing completed'
})
}
})Execute on Root nodes (re-runs if AST changes):
const rootPlugin = () => ({
postcssPlugin: 'root-plugin',
Root(root, { result }) {
// Process the entire stylesheet
let ruleCount = 0
root.walkRules(() => ruleCount++)
if (ruleCount > 100) {
result.warn('Large stylesheet detected', { node: root })
}
},
RootExit(root, { result }) {
// Final processing after all other plugins
root.append(postcss.comment({
text: `Generated by ${result.processor.version}`
}))
}
})Process specific node types:
const nodePlugin = () => ({
postcssPlugin: 'node-plugin',
Rule(rule, { result }) {
// Process every rule
if (rule.selector.startsWith('.old-')) {
rule.selector = rule.selector.replace('.old-', '.new-')
}
},
Declaration(decl, { result }) {
// Process every declaration
if (decl.prop === 'color' && decl.value === 'red') {
result.warn('Avoid hardcoded red color', { node: decl })
}
},
Comment(comment, { result }) {
// Process every comment
if (comment.text.includes('TODO')) {
result.warn('TODO found in CSS', { node: comment })
}
}
})Process specific at-rules or declarations by name:
const filteredPlugin = () => ({
postcssPlugin: 'filtered-plugin',
// Process only @media at-rules
AtRule: {
media(atRule, { result }) {
// Add min-width if only max-width specified
if (atRule.params.includes('max-width') && !atRule.params.includes('min-width')) {
atRule.params = `${atRule.params} and (min-width: 320px)`
}
},
import(atRule, { result }) {
result.warn('Consider bundling imports', { node: atRule })
}
},
// Process only specific properties
Declaration: {
'font-size'(decl, { result }) {
if (decl.value.includes('px')) {
decl.value = decl.value.replace(/(\d+)px/g, (match, num) => {
return `${num / 16}rem`
})
}
},
'margin'(decl, { result }) {
if (decl.value === '0px') {
decl.value = '0'
}
}
}
})The helper object provides utilities for plugin development:
const helperPlugin = () => ({
postcssPlugin: 'helper-plugin',
Declaration(decl, { result, postcss }) {
// Access result for warnings and messages
result.warn('Processing declaration', { node: decl })
// Access postcss constructor functions
const newRule = postcss.rule({ selector: '.generated' })
newRule.append(postcss.decl({ prop: 'content', value: '""' }))
// Insert generated nodes
decl.root().append(newRule)
// Add custom messages
result.messages.push({
type: 'custom',
plugin: 'helper-plugin',
declaration: decl.prop,
value: decl.value
})
}
})Plugins can return promises for async operations:
const asyncPlugin = () => ({
postcssPlugin: 'async-plugin',
async Once(root, { result }) {
// Async setup
const config = await loadConfiguration()
result.messages.push({ type: 'config', data: config })
},
async AtRule: {
async import(atRule, { result }) {
try {
// Async file loading
const content = await fs.readFile(atRule.params.slice(1, -1), 'utf8')
const importedRoot = postcss.parse(content)
// Replace @import with actual content
atRule.replaceWith(importedRoot.nodes)
} catch (error) {
result.warn(`Failed to import: ${error.message}`, { node: atRule })
}
}
}
})Use prepare() for plugins that need per-result initialization:
const statefulPlugin = (opts = {}) => ({
postcssPlugin: 'stateful-plugin',
prepare(result) {
// Initialize state per processing run
const state = {
ruleCount: 0,
declarations: new Set(),
startTime: Date.now()
}
return {
Rule(rule) {
state.ruleCount++
},
Declaration(decl) {
state.declarations.add(decl.prop)
},
OnceExit(root) {
const duration = Date.now() - state.startTime
result.messages.push({
type: 'stats',
plugin: 'stateful-plugin',
ruleCount: state.ruleCount,
uniqueProps: state.declarations.size,
duration
})
}
}
}
})PostCSS supports older plugin formats for backward compatibility:
interface Transformer extends TransformCallback {
postcssPlugin: string
postcssVersion: string
}
interface TransformCallback {
(root: Root, result: Result): Promise<void> | void
}
type AcceptedPlugin =
| { postcss: Processor | TransformCallback }
| OldPlugin<any>
| Plugin
| PluginCreator<any>
| Processor
| TransformCallback// Function-based legacy plugin
const legacyFunction = (root, result) => {
root.walkRules(rule => {
if (rule.selector === '.old') {
rule.selector = '.new'
}
})
}
// Object-based legacy plugin
const legacyObject = {
postcss: (root, result) => {
root.walkDecls('color', decl => {
if (decl.value === 'red') {
decl.value = 'blue'
}
})
}
}
// Use legacy plugins
const processor = postcss([
legacyFunction,
legacyObject,
modernPlugin()
])const valueTransformPlugin = (transforms = {}) => ({
postcssPlugin: 'value-transform',
Declaration(decl) {
const transform = transforms[decl.prop]
if (transform) {
decl.value = transform(decl.value)
}
}
})
// Usage
const processor = postcss([
valueTransformPlugin({
'font-size': (value) => value.replace(/px$/, 'rem'),
'color': (value) => value === 'red' ? '#ff0000' : value
})
])const selectorPlugin = (options = {}) => {
const { prefix = '', suffix = '' } = options
return {
postcssPlugin: 'selector-plugin',
Rule(rule) {
rule.selector = rule.selectors
.map(selector => `${prefix}${selector}${suffix}`)
.join(', ')
}
}
}const cssModulesPlugin = () => {
const classMap = new Map()
return {
postcssPlugin: 'css-modules',
prepare() {
return {
Rule(rule) {
rule.selectors = rule.selectors.map(selector => {
const match = selector.match(/\.(\w+)/)
if (match) {
const className = match[1]
const hashedName = `_${className}_${hash(className)}`
classMap.set(className, hashedName)
return selector.replace(className, hashedName)
}
return selector
})
},
OnceExit(root, { result }) {
result.messages.push({
type: 'css-modules',
plugin: 'css-modules',
classMap: Object.fromEntries(classMap)
})
}
}
}
}
}const validationPlugin = (rules = {}) => ({
postcssPlugin: 'validation-plugin',
Declaration(decl, { result }) {
// Check for deprecated properties
const deprecated = ['float', 'clear', 'vertical-align']
if (deprecated.includes(decl.prop)) {
result.warn(`Property ${decl.prop} is deprecated`, {
node: decl,
word: decl.prop
})
}
// Check for invalid values
if (decl.prop === 'display' && !['block', 'inline', 'flex', 'grid'].includes(decl.value)) {
result.warn(`Invalid display value: ${decl.value}`, {
node: decl,
word: decl.value
})
}
},
Rule(rule, { result }) {
// Check for empty rules
if (!rule.nodes || rule.nodes.length === 0) {
result.warn('Empty rule detected', { node: rule })
}
// Check for overly specific selectors
const specificity = calculateSpecificity(rule.selector)
if (specificity > 1000) {
result.warn('High specificity selector', {
node: rule,
word: rule.selector
})
}
}
})const postcss = require('postcss')
// Test plugin functionality
async function testPlugin() {
const plugin = myPlugin({ option: 'value' })
const processor = postcss([plugin])
const input = '.foo { color: red; }'
const result = await processor.process(input, { from: 'test.css' })
console.log('Output:', result.css)
console.log('Warnings:', result.warnings())
console.log('Messages:', result.messages)
}
// Test with different inputs
const testCases = [
{ input: '.foo { color: red; }', expected: '.foo { color: blue; }' },
{ input: '.bar { font-size: 16px; }', expected: '.bar { font-size: 1rem; }' }
]
testCases.forEach(async ({ input, expected }) => {
const result = await processor.process(input)
console.assert(result.css === expected, `Expected ${expected}, got ${result.css}`)
})const robustPlugin = () => ({
postcssPlugin: 'robust-plugin',
Declaration(decl, { result }) {
try {
// Risky operation
const parsed = parseComplexValue(decl.value)
decl.value = transformValue(parsed)
} catch (error) {
// Report error with context
const cssError = decl.error(`Failed to process ${decl.prop}: ${error.message}`)
throw cssError
}
},
Rule(rule, { result }) {
// Validate selector
try {
validateSelector(rule.selector)
} catch (error) {
result.warn(`Invalid selector: ${error.message}`, {
node: rule,
word: rule.selector
})
}
}
})