CSShighoptimization
Remove unused CSS rules
rule · unused-css
Removing unused CSS reduces bundle sizes, improves loading performance, and maintains cleaner codebases by eliminating dead code and unused selectors.
Code Examples
Performance Impact
CSS
/* Example: Large CSS framework with unused styles */
/* Bootstrap CSS: ~160KB minified */
/* But project only uses: buttons, grid, utilities */
/* Unused code: ~120KB (75% unused) */
/* ❌ All of Bootstrap loaded */
@import 'bootstrap/dist/css/bootstrap.min.css';
/* ✅ Only needed components */
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins';
@import 'bootstrap/scss/grid';
@import 'bootstrap/scss/buttons';
@import 'bootstrap/scss/utilities';Common Sources of Unused CSS
CSS
/* 1. Legacy styles from removed features */
.old-feature-class {
/* This component was removed 6 months ago */
display: block;
background: red;
}
/* 2. Development/testing styles */
.debug-border {
border: 1px solid red !important;
}
.temp-style {
/* TODO: Remove this after testing */
background: yellow;
}
/* 3. Copy-pasted styles never used */
.copied-from-another-project {
/* Copied but never actually used */
position: absolute;
top: 50%;
left: 50%;
}
/* 4. Framework styles for unused components */
.modal-backdrop,
.carousel-indicators,
.breadcrumb {
/* Bootstrap components never used in project */
}
/* 5. Media query styles for unsupported devices */
@media screen and (max-width: 320px) {
/* Supporting very old devices */
.legacy-mobile {
font-size: 12px;
}
}Why It Matters
Most projects use only 10-25% of their CSS—removing the rest can cut bundle sizes by 75%+ and dramatically improve load times.
Manual Detection Methods
Chrome DevTools Coverage
JavaScript
// Open Chrome DevTools -> Sources -> Coverage
// 1. Click record button
// 2. Navigate through your application
// 3. Stop recording
// 4. View unused CSS highlighted in red
// You can also use the Coverage API programmatically
async function analyzeCSSCoverage() {
const coverage = await page.coverage.startCSSCoverage()
// Navigate through pages
await page.goto('https://example.com')
await page.click('#menu-button')
await page.goto('https://example.com/about')
const cssCoverage = await page.coverage.stopCSSCoverage()
cssCoverage.forEach(entry => {
const unusedBytes = entry.ranges
.filter(range => !range.start && !range.end)
.reduce((total, range) => total + range.end - range.start, 0)
const percentageUsed = ((entry.text.length - unusedBytes) / entry.text.length) * 100
console.log(`${entry.url}: ${percentageUsed.toFixed(2)}% used`)
})
}Browser DevTools Audit
HTML
<!-- Use DevTools Lighthouse audit -->
<!-- Performance section shows "Remove unused CSS" opportunities -->
<!-- Lists specific stylesheets and estimated savings -->Automated Tools
PurgeCSS (Most Popular)
Shell
# Installation
npm install --save-dev @fullhuman/postcss-purgecss
npm install --save-dev purgecss
# CLI usage
npx purgecss --css styles.css --content index.html --output build/PurgeCSS Configuration
JavaScript
// purgecss.config.js
module.exports = {
content: [
'./src/**/*.html',
'./src/**/*.js',
'./src/**/*.jsx',
'./src/**/*.ts',
'./src/**/*.tsx',
'./src/**/*.vue'
],
css: ['./src/**/*.css', './src/**/*.scss'],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
safelist: [
// Whitelist classes that should never be removed
'btn',
'active',
'show',
/^btn-/, // All classes starting with btn-
{
pattern: /^carousel-/,
variants: ['sm', 'md', 'lg']
}
],
blocklist: [
// Classes to always remove
'unused-class',
'old-style'
],
keyframes: true, // Remove unused keyframes
fontFace: true, // Remove unused font-face rules
variables: true // Remove unused CSS custom properties
}PostCSS Integration
JavaScript
// postcss.config.js
const purgecss = require('@fullhuman/postcss-purgecss')
module.exports = {
plugins: [
require('autoprefixer'),
...(process.env.NODE_ENV === 'production' ? [
purgecss({
content: ['./src/**/*.html', './src/**/*.js'],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || []
})
] : [])
]
}UnCSS
Shell
# Installation
npm install --save-dev uncss
# CLI usage
uncss index.html > cleaned.css
uncss http://localhost:3000 > cleaned.cssUnCSS Configuration
JavaScript
// uncss-config.js
const uncss = require('uncss')
const options = {
html: ['index.html', 'about.html', 'contact.html'],
css: ['css/bootstrap.css', 'css/main.css'],
ignore: [
// CSS selectors to ignore
/\.js-/, // Classes starting with js-
/\.is-/, // State classes
/\.has-/, // Modifier classes
'.loaded',
'.error',
'.success'
],
ignoreSheets: [/fonts.googleapis/], // Ignore external stylesheets
timeout: 1000, // Wait time for JS execution
htmlroot: 'public', // Root directory for HTML files
report: false // Generate coverage report
}
uncss(options, (error, output) => {
if (error) {
throw error
}
console.log('Cleaned CSS length:', output.length)
require('fs').writeFileSync('dist/cleaned.css', output)
})Critical CSS with Unused CSS Removal
JavaScript
// critical-purge.js
const critical = require('critical')
const purgecss = require('purgecss')
const fs = require('fs')
async function optimizeCSS() {
// Step 1: Extract critical CSS
const criticalCSS = await critical.generate({
base: 'dist/',
src: 'index.html',
width: 1300,
height: 900,
inline: false
})
// Step 2: Purge unused CSS from main stylesheet
const purgedCSS = await purgecss.purge({
content: ['dist/**/*.html', 'dist/**/*.js'],
css: ['dist/styles.css']
})
// Step 3: Write optimized CSS files
fs.writeFileSync('dist/critical.css', criticalCSS)
fs.writeFileSync('dist/main.css', purgedCSS[0].css)
console.log('CSS optimization complete')
}
optimizeCSS()Framework-Specific Implementation
Webpack with PurgeCSS
JavaScript
// webpack.config.js
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { PurgeCSSPlugin } = require('purgecss-webpack-plugin')
const glob = require('glob')
const PATHS = {
src: path.join(__dirname, 'src')
}
module.exports = {
mode: 'production',
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css'
}),
new PurgeCSSPlugin({
paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
safelist: {
standard: ['body', 'html', /^btn-/, /^modal/],
deep: [/^dropdown/],
greedy: [/^carousel-/]
},
defaultExtractor: (content) => {
// Extract class names, IDs, and other CSS selectors
const broadMatches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || []
const innerMatches = content.match(/[^<>"'`\s.()]*[^<>"'`\s.():]/g) || []
return broadMatches.concat(innerMatches)
}
})
]
}Next.js Integration
JavaScript
// next.config.js
const withPurgeCss = require('next-purgecss')
module.exports = withPurgeCss({
purgeCssPaths: [
'pages/**/*',
'components/**/*'
],
purgeCss: {
whitelist: () => ['html', 'body'],
whitelistPatterns: () => [/^__next/, /^btn-/, /^alert-/]
}
})
// Alternative with PostCSS
// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
...(process.env.NODE_ENV === 'production' ? {
'@fullhuman/postcss-purgecss': {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}'
],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || []
}
} : {})
}
}Vue.js with PurgeCSS
JavaScript
// vue.config.js
const path = require('path')
const { PurgeCSSPlugin } = require('purgecss-webpack-plugin')
const glob = require('glob')
module.exports = {
configureWebpack: (config) => {
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
new PurgeCSSPlugin({
paths: glob.sync([
path.join(__dirname, './src/index.html'),
path.join(__dirname, './src/**/*.vue'),
path.join(__dirname, './src/**/*.js')
]),
extractors: [
{
extractor: content => content.match(/[\w-/.:]+(?<!:)/g) || [],
extensions: ['vue', 'js', 'html']
}
],
safelist: ['body', 'html', /^router-/, /^v-/]
})
)
}
}
}React with CSS Modules
JavaScript
// webpack.config.js for CSS Modules
const { PurgeCSSPlugin } = require('purgecss-webpack-plugin')
const glob = require('glob')
module.exports = {
plugins: [
new PurgeCSSPlugin({
paths: glob.sync('./src/**/*', { nodir: true }),
extractors: [
{
extractor: (content) => {
// Extract CSS Modules class names
const matches = content.match(/[\w-/:]+(?<!:)/g) || []
// Also extract from import statements
const importMatches = content.match(/import\s+.*\s+from\s+['"][^'"]*\.module\.css['"];?/g) || []
return matches.concat(importMatches)
},
extensions: ['js', 'jsx', 'ts', 'tsx']
}
],
safelist: {
// Preserve CSS Modules hashes
standard: [/^_[\w-]+_\w+$/]
}
})
]
}Advanced Techniques
Dynamic Content Handling
JavaScript
// dynamic-purge.js
const purgecss = require('purgecss')
const puppeteer = require('puppeteer')
class DynamicPurgeCSS {
constructor(options = {}) {
this.browser = null
this.pages = options.pages || []
this.interactions = options.interactions || []
}
async init() {
this.browser = await puppeteer.launch()
}
async extractUsedClasses() {
const usedClasses = new Set()
for (const pageUrl of this.pages) {
const page = await this.browser.newPage()
// Track CSS usage
await page.coverage.startCSSCoverage()
await page.goto(pageUrl)
// Perform interactions to trigger dynamic styles
for (const interaction of this.interactions) {
await this.performInteraction(page, interaction)
await page.waitForTimeout(500) // Wait for animations
}
const coverage = await page.coverage.stopCSSCoverage()
// Extract used selectors from coverage
coverage.forEach(entry => {
entry.ranges.forEach(range => {
if (range.count > 0) {
const usedCSS = entry.text.slice(range.start, range.end)
const selectors = this.extractSelectorsFromCSS(usedCSS)
selectors.forEach(selector => usedClasses.add(selector))
}
})
})
await page.close()
}
return Array.from(usedClasses)
}
async performInteraction(page, interaction) {
switch (interaction.type) {
case 'click':
await page.click(interaction.selector)
break
case 'hover':
await page.hover(interaction.selector)
break
case 'scroll':
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
break
case 'resize':
await page.setViewport({ width: interaction.width, height: interaction.height })
break
}
}
extractSelectorsFromCSS(css) {
// Simple regex to extract class names and IDs
const classMatches = css.match(/\.[\w-]+/g) || []
const idMatches = css.match(/#[\w-]+/g) || []
return [...classMatches, ...idMatches]
}
async purgeWithDynamicAnalysis(cssFiles) {
const usedClasses = await this.extractUsedClasses()
const result = await purgecss.purge({
content: [{
raw: usedClasses.join(' '),
extension: 'html'
}],
css: cssFiles,
safelist: usedClasses
})
await this.browser.close()
return result
}
}
// Usage
const dynamicPurge = new DynamicPurgeCSS({
pages: [
'http://localhost:3000',
'http://localhost:3000/about',
'http://localhost:3000/contact'
],
interactions: [
{ type: 'click', selector: '.menu-toggle' },
{ type: 'hover', selector: '.dropdown-trigger' },
{ type: 'scroll' },
{ type: 'resize', width: 768, height: 1024 }
]
})
dynamicPurge.init().then(() => {
return dynamicPurge.purgeWithDynamicAnalysis(['dist/styles.css'])
}).then(result => {
console.log('Purged CSS:', result[0].css.length)
})Component-Based Purging
JavaScript
// component-purge.js
const fs = require('fs')
const path = require('path')
const purgecss = require('purgecss')
class ComponentBasedPurge {
constructor(componentsDir, stylesDir) {
this.componentsDir = componentsDir
this.stylesDir = stylesDir
}
analyzeComponentUsage() {
const components = this.scanComponents()
const usageMap = new Map()
components.forEach(component => {
const dependencies = this.extractDependencies(component)
const styles = this.extractStyles(component)
usageMap.set(component.name, {
file: component.file,
dependencies,
styles,
used: false
})
})
// Mark used components
this.markUsedComponents(usageMap, 'App') // Start from App component
return usageMap
}
scanComponents() {
const components = []
const componentFiles = fs.readdirSync(this.componentsDir)
componentFiles.forEach(file => {
if (file.endsWith('.js') || file.endsWith('.jsx') || file.endsWith('.tsx')) {
const content = fs.readFileSync(path.join(this.componentsDir, file), 'utf8')
components.push({
name: path.basename(file, path.extname(file)),
file,
content
})
}
})
return components
}
extractDependencies(component) {
// Extract component imports
const importRegex = /import\s+(\w+)\s+from\s+['"][^'"]*['"]/g
const dependencies = []
let match
while ((match = importRegex.exec(component.content)) !== null) {
dependencies.push(match[1])
}
return dependencies
}
extractStyles(component) {
// Extract CSS classes used in component
const classRegex = /className=['"]([^'"]*)['"]/g
const styles = new Set()
let match
while ((match = classRegex.exec(component.content)) !== null) {
match[1].split(' ').forEach(className => {
if (className.trim()) {
styles.add(className.trim())
}
})
}
return Array.from(styles)
}
markUsedComponents(usageMap, componentName) {
const component = usageMap.get(componentName)
if (!component || component.used) return
component.used = true
// Recursively mark dependencies as used
component.dependencies.forEach(dep => {
this.markUsedComponents(usageMap, dep)
})
}
async generateOptimizedCSS() {
const usageMap = this.analyzeComponentUsage()
const usedClasses = new Set()
// Collect all classes from used components
for (const [name, component] of usageMap) {
if (component.used) {
component.styles.forEach(style => usedClasses.add(style))
}
}
// Purge CSS based on used classes
const result = await purgecss.purge({
content: [{
raw: Array.from(usedClasses).join(' '),
extension: 'html'
}],
css: [path.join(this.stylesDir, 'main.css')],
safelist: Array.from(usedClasses)
})
return result[0].css
}
}
// Usage
const componentPurge = new ComponentBasedPurge('./src/components', './src/styles')
componentPurge.generateOptimizedCSS().then(optimizedCSS => {
fs.writeFileSync('./dist/optimized.css', optimizedCSS)
console.log('Component-based purging complete')
})Build Pipeline Integration
GitHub Actions CSS Optimization
YAML
# .github/workflows/css-optimization.yml
name: CSS Optimization
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
optimize-css:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Analyze CSS usage
run: |
npm install -g purgecss
purgecss --css dist/**/*.css --content dist/**/*.html --output analysis/
# Generate report
echo "CSS_SIZE_BEFORE=$(du -sh dist/css/ | cut -f1)" >> $GITHUB_ENV
echo "CSS_SIZE_AFTER=$(du -sh analysis/ | cut -f1)" >> $GITHUB_ENV
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## CSS Optimization Results
📦 **CSS Size Before:** ${process.env.CSS_SIZE_BEFORE}
✨ **CSS Size After:** ${process.env.CSS_SIZE_AFTER}
Run \`npm run css:optimize\` to apply optimizations.`
})Performance Monitoring
JavaScript
// css-performance-monitor.js
const fs = require('fs')
const gzipSize = require('gzip-size')
class CSSPerformanceMonitor {
constructor() {
this.metrics = {
before: {},
after: {},
savings: {}
}
}
async analyzeCSSFile(filePath, label = 'css') {
const content = fs.readFileSync(filePath, 'utf8')
const size = Buffer.byteLength(content, 'utf8')
const gzipped = await gzipSize(content)
return {
path: filePath,
size,
gzipped,
sizeFormatted: this.formatBytes(size),
gzippedFormatted: this.formatBytes(gzipped)
}
}
async compareOptimization(beforePath, afterPath) {
const before = await this.analyzeCSSFile(beforePath, 'before')
const after = await this.analyzeCSSFile(afterPath, 'after')
const sizeSavings = before.size - after.size
const gzipSavings = before.gzipped - after.gzipped
const percentSaved = Math.round((sizeSavings / before.size) * 100)
const gzipPercentSaved = Math.round((gzipSavings / before.gzipped) * 100)
return {
before,
after,
savings: {
size: sizeSavings,
gzipped: gzipSavings,
percent: percentSaved,
gzipPercent: gzipPercentSaved,
sizeFormatted: this.formatBytes(sizeSavings),
gzippedFormatted: this.formatBytes(gzipSavings)
}
}
}
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
generateReport(comparison) {
console.log('\n=== CSS Optimization Report ===')
console.log(`Before: ${comparison.before.sizeFormatted} (${comparison.before.gzippedFormatted} gzipped)`)
console.log(`After: ${comparison.after.sizeFormatted} (${comparison.after.gzippedFormatted} gzipped)`)
console.log(`Savings: ${comparison.savings.sizeFormatted} (${comparison.savings.percent}%)`)
console.log(`Gzipped Savings: ${comparison.savings.gzippedFormatted} (${comparison.savings.gzipPercent}%)`)
}
}
// Usage
const monitor = new CSSPerformanceMonitor()
monitor.compareOptimization('dist/styles.css', 'dist/styles.purged.css').then(report => {
monitor.generateReport(report)
})Best Practices
- Automate in Build: Integrate unused CSS removal in production builds
- Safelist Critical Classes: Protect important utility and state classes
- Test Thoroughly: Verify all functionality after CSS purging
- Monitor Performance: Track bundle size reductions over time
- Component-Based: Use component analysis for accurate purging
- Dynamic Content: Account for JavaScript-generated content
- Framework Awareness: Use framework-specific extraction patterns
- Regular Auditing: Periodically review and clean CSS files
- Team Education: Train developers on CSS optimization practices
- Gradual Implementation: Start with conservative purging and refine
Common Pitfalls and Solutions
Dynamic Class Names
JavaScript
// ❌ Problem: Dynamic classes not detected
const buttonType = 'primary'
const className = `btn-${buttonType}` // PurgeCSS might miss this
// ✅ Solution: Safelist pattern
// purgecss.config.js
{
safelist: [
/^btn-/, // Safelist all button variants
]
}
// ✅ Alternative: Explicit class names
const getButtonClass = (type) => {
// Explicit class names for PurgeCSS to detect
const classes = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger'
}
return classes[type] || 'btn-primary'
}Third-Party Components
JavaScript
// ❌ Problem: Third-party component styles removed
import 'react-datepicker/dist/react-datepicker.css'
// ✅ Solution: Exclude from purging
{
content: ['./src/**/*.js'],
css: ['./src/**/*.css'],
rejected: ['./node_modules/react-datepicker/dist/react-datepicker.css']
}
// ✅ Alternative: Safelist vendor classes
{
safelist: [
/^react-datepicker/,
/^rdp-/ // React date picker classes
]
}Conditional Styling
CSS
/* ❌ Problem: Conditional styles might be removed */
.dark-mode .button {
background: #333;
}
.is-mobile .nav {
display: none;
}JavaScript
// ✅ Solution: Include conditional HTML in content
{
content: [
'./src/**/*.js',
{
raw: '<div class="dark-mode"><div class="is-mobile">',
extension: 'html'
}
]
}Remember to always test your application thoroughly after implementing unused CSS removal to ensure no critical styles are accidentally removed.
Verification
Automated Checks
- Confirm the computed styles match the intended fix in DevTools.
- If the rule affects motion, contrast, or layout stability, verify those user-facing outcomes directly.
Manual Checks
- Inspect the rendered UI at the breakpoints and interaction states affected by the rule.
- Test at least one mobile and one desktop viewport before shipping.