Custom WordPress site specialists - web design, hosting and online growth

Remove unused CSS classes automatically

Buy Me a Coffee
Remove unused CSS class names automatically

This article focuses on how to remove unused CSS classes, but the same methods can apply to tag names as well. You can even optimize your JavaScript with the resources, after you’ve implemented the solution that I will explain in this article.

However, instead of removing unused JavaScript code, you conditionally add JavaScript code by looking in the list of used classes (using PHP code).

Removing unused CSS and JavaScript will definitely help a lot for your Core Web Vitals optimization.

The code shown in this article is not ready-to-use; you should modify it. The code is meant to give you an idea of how you can remove unused CSS classes by yourself.

Tracking used classes by pages

What you have to do first is create two JSON files:

  1. Filename: classes-by-page-id.json
    Contents: {}
  2. Filename: classes-by-pages.json
    Contents: []

The first file will contain what classes are used by which page (ID of page).

All classes from the first JSON file are merged into a single list of classes and are put into the second JSON file (classes-by-pages.json).

In order for the fact that you need two files to make sense, I’ll have to tell you that the CSS file that you are going to optimize, has to make use of a version-parameter.

The value for the version parameter is equal to when the second file (classes-by-pages.json) has been last modified as a Unix timestamp, for instance: ?v=1636559118.

If you keep modifying the file, even though the contents are the same (no new classes, and no removed CSS classes), then the version changes too often.

Another reason is that if a page is removed, or when it doesn’t use a certain class anymore, how are you supposed to know whether or not other pages might still use it; just by looking at the contents of the second JSON file (classes-by-pages.json)?

That’s right, you can’t. Actually, you can, but then you have to query the whole database for all pages to look for classes inside of the content, every time you update a page.

Obviously, the latter is very inefficient and will not catch all classes either.

Getting all classes from the page

Whenever you update a page, the content is sent through either POST or AJAX. What you have to do is put all elements’ classes into an array, convert it to a JSON string and store it in an input field or send it along with the AJAX call.

var allUsedClasses = [];
pageContent.querySelectorAll('[class]').forEach(function(el) {
  el.classList.forEach(function(elClass) {
    if(!allUsedClasses.includes(elClass)) {
      allUsedClasses.push(elClass);
    }
  });
});
inputFieldForUsedClasses.value = JSON.stringify(allUsedClasses);

Variable pageContent refers to the element that wraps the content that is specific to the page, not the elements surrounding it: header, footer, etc. This is to prevent classes that are added by template files, from being added to the classes added by pages.

I will discuss how to keep track of classes added by template files not much further into this article. It is more complicated and it takes way more effort than keeping track of classes added by pages, as those are mostly static.

Updating the first JSON file (classes-by-page-id.json)

Since you’ve received the list of classes used by the page to be updated, you should now create an empty array. That array should contain lists of classes, by page ID.

The array should store the new list of classes for the page to be updated, which is easy as you’ve just received that list through either POST or an AJAX call.

Then, the updated array of classes by page should be converted to a JSON string, and will then be the new content for the first JSON file (classes-by-page-id.json).

$currentPageID = $_POST['page_id'];
$usedClassesByCurrentPage = $_POST['used_classes'];

$filePathUsedClassesByPage = 'classes-by-page-id.json';
$jsonUsedClassesByPage = json_decode (
  file_get_contents($filePathUsedClassesByPage),
  true
);
$jsonUsedClassesByCurrentPage = json_decode (
  $usedClassesByCurrentPage,
  true
);

$jsonUsedClassesByPage[$currentPageID] = $jsonUsedClassesByCurrentPage;
$jsonStringUsedClassesByPage = json_encode($jsonUsedClassesByPage);

file_put_contents($filePathUsedClassesByPage, $jsonStringUsedClassesByPage);

Deciding whether or not to update the second JSON file

The second JSON file should, as I said, only be updated if the content of the file is about to be changed. To do this, all you have to do is create a sorted list of unique classes, convert it to a JSON string, and compare it to the contents of the second JSON file.

$filePathUsedClasses = 'classes-by-pages.json';
$jsonStringPreviouslyUsedClasses = file_get_contents($filePathUsedClasses);
$usedClasses = [];

foreach($jsonUsedClassesByPage as $pageID => $usedClassesByThisPage) {
  if($currentPageID !== $pageID) {
    array_push (
      $usedClasses,
      ...$usedClassesByThisPage
    );
  }
}
array_push (
  $usedClasses,
  ...$jsonUsedClassesByCurrentPage
);
$usedClasses = array_values(array_unique($usedClasses));
sort($usedClasses);
$jsonStringUsedClasses = json_encode($usedClasses);

if($jsonStringPreviouslyUsedClasses !== $jsonStringUsedClasses) {
  file_put_contents($filePathUsedClasses, $jsonStringUsedClasses);
} else {
  // nothing changed, so don't update the JSON file (classes-by-pages.json)
}

$lastModifiedClassesByTemplates = filemtime($filePathClassesByTemplates);

Tracking classes added by template files

After keeping track of all classes by pages, I came across the problem that some pages are generated using template files. Product pages, for instance, add classes as well. But wait, there is more: classes they add can also depend on the situation.

For example, a shopping cart with items might add classes for those items. Now, what is going to happen if the shopping cart is empty? Well, then it does not add those classes, and then guess what.. there will be a difference in the list of classes added by template files.

To prevent that from happening, I used suffixes to append to the file path of the template file when saving the classes added by every template file.

All classes added within the if-statement that executes whenever there are items in the cart, will be added to an array with the key /some_path/cart.php:not_empty.

This is where I realized that it takes a lot more effort

It took a lot more effort, because I had to edit every template file in the WordPress theme I’ve built for this website.

Every template file had to use a function that keeps track of what classes are used by which template files.

That function is written in a file that has to get included by every template file, and it creates a namespace so that you can use only the functions that a specific template file needs. This is what the file (extra-functions.php) looks like:

namespace ExtraFunctions;

function addClasses($classes, $modifier = false, $caller = false) {
  global $classesAddedByTemplates;
  
  if($caller === false) {
    $caller = str_replace(get_template_directory(), '', debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 1)[0]['file']);
  }
  if($modifier !== false) {
    $caller .= ':' . strval($modifier);
  }
  
  if(!isset($classesAddedByTemplates[$caller])) {
    $classesAddedByTemplates[$caller] = [];
  }
  
  array_push (
    $classesAddedByTemplates[$caller],
    ...explode(' ', $classes)
  );
  
  $classesAddedByTemplates[$caller] = array_unique($classesAddedByTemplates[$caller]);
  
  return $classes;
}

So, the function keeps tracks of all classes added by what templates and in what situation (using modifiers). In order to be able to use functions in the namespace, you require the PHP file once, in a file to which your template files have access to it’s global variables, and use the function. The following example loads the PHP file:

require_once(__DIR__ . '/extra-functions.php');

The following example uses a function inside the namespace:

use function ExtraFunctionsaddClasses;

In a callback function that I use to filter the final HTML output, there is also a piece of code that iterates through the whole array of classes, and decides whether or not to update the JSON files. This is what that looks like:

global $classesAddedByTemplates;
$filePathClassesByTemplateFile = get_template_directory() . '/classes-by-template-file.json';
$contentClassesByTemplateFile = file_get_contents($filePathClassesByTemplateFile);
$jsonClassesByTemplateFile = json_decode (
  $contentClassesByTemplateFile,
  true
);

foreach($classesAddedByTemplates as $templateFile => $classesByTemplateFile) {
  $jsonClassesByTemplateFile[$templateFile] = $classesByTemplateFile;
}

$jsonStringClassesByTemplateFiles = json_encode($jsonClassesByTemplateFile);

$filePathClassesByTemplates = get_template_directory() . '/classes-by-templates.json';

if($jsonStringClassesByTemplateFiles !== $contentClassesByTemplateFile) {
  file_put_contents($filePathClassesByTemplateFile, $jsonStringClassesByTemplateFiles);
  
  $contentClassesByTemplates = file_get_contents($filePathClassesByTemplates);
  
  $classesByTemplates = [];
  foreach($jsonClassesByTemplateFile as $templateFile => $classesByTemplateFile) {
    foreach($classesByTemplateFile as $classByTemplateFile) {
      if(!in_array($classByTemplateFile, $classesByTemplates)) {
        array_push (
          $classesByTemplates,
          $classByTemplateFile
        );
      }
    }
  }
  sort($classesByTemplates);
  
  $jsonStringClassesByTemplates = json_encode($classesByTemplates);
  if($jsonStringClassesByTemplates !== $contentClassesByTemplates) {
    file_put_contents($filePathClassesByTemplates, $jsonStringClassesByTemplates);
  }
  
}

Functionality to remove unused CSS classes

In order to be able to automatically remove unused CSS classes in the CSS file, you would have to turn the CSS file into a PHP file.

Then, you have to add a .htaccess file in the same folder. That file will make sure that a request for, let’s say, stylesheet.css will get handled by stylesheet.php. This is what the .htaccess file looks like:

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule stylesheet.css stylesheet.php [L]

Now, the PHP/CSS file should contain the following PHP code in the beginning:

header('Content-type: text/css', false);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime(__FILE__)) . ' GMT', false);
header_remove('X-Powered-By');

ob_start();

This makes sure that the Content-Type HTTP header indicates that it is a CSS file. It also starts output buffering using PHP function ob_start, in order to be able to collect all CSS code before outputting optimized CSS.

In order to know what classes are used, you have to get the contents of the following JSON files, and turn it into a usable array: classes-by-pages.json and classes-by-templates.json. Here’s how:

$classesByPagesJSON = file_get_contents('classes-by-pages.json');
$classesByPages = json_decode($classesByPagesJSON, true);

$classesByTemplatesJSON = file_get_contents('classes-by-templates.json');
$classesByTemplates = json_decode($classesByTemplatesJSON, true);

At the end of the PHP/CSS file the output buffering is stopped and the output is retrieved using PHP function ob_get_clean and put in a variable named $css.

I have created some PHP classes (not to be confused with CSS classes), which are necessary to be able to fully and partially deconstruct CSS queries to remove unused CSS classes:

  • CSSDocument
  • CSSAtRule
  • CSSSelector
  • CSSQuery
  • CSSDeclaration

The PHP classes above are sorted by depth of which such an element, such as a CSS declaration, appears inside of a CSS document.

Before showing the code, I’d like to explain why it’s necessary to cut the whole CSS document into bits and pieces (in the form of specific PHP objects).

If I were to remove unused CSS class menu from the following selector:

ul.menu {
  /* ... */
}

then I should remove the whole rule set, instead of removing the unused CSS class. However, if I were to remove unused CSS class menu from the following selector:

ul:not(.menu) {
  /* ... */
}

then, as I remove :not(.menu), the selector becomes less specific due to the loss of a class inside of CSS’s negation function :not().

Instead, the class should get replaced by a placeholder class:

ul:not(.?) {
  /* ... */
}

That way, it’s just as specific, but the unused class is replaced by a placeholder class.

You could also use a single underscore (._), instead of a backward slash escaped question mark (.?); that’s even shorter. It is suitable as a placeholder class as long as it’s short (word play), and not used in HTML.

Using regex would require you to create a pattern for a whole CSS rule set, which consists of: the selector, queries of the selector and declarations.

I found it more debuggable if I were to write it in seperate steps (in the form of specific PHP objects), so here it is:

class CSSDeclaration {
  public $property;
  public $value;
  
  public $ruleSet; // parent
  
  public function __construct (
    $property,
    $value
  ) {
    $this -> property = $property;
    $this -> value = $value;
  }
}
class CSSQuery {
  public $queryString;

  public $queryKey;
  
  public $selector; // parent
  
  public function __construct (
    $queryString,
    CSSSelector $selector,
    $queryKey
  ) {
    preg_match_all (
      '/[ >]|::[^ :>]+|[@:#.]?[^ :>]+|*/',
      $queryString,
      $piecesMatch
    );
    $this -> queryString = $queryString;
    
    $this -> selector = $selector;
    $this -> queryKey = $queryKey;
  }
  
  public function __destruct() {
    if(count($this -> selector -> queries) < 2) {
      unset($this -> selector -> ruleSet);
    }
  }
}
class CSSSelector {
  public $queries = [];
  public $originalQueryAmount = 0;
  
  public $ruleSet; // parent
  
  public function __construct (
    $selector
  ) {
    preg_match_all (
      '/[^,{}]+/',
      $selector,
      $queriesMatch
    );
    $queriesMatch = $queriesMatch[0];
    
    foreach($queriesMatch as $queryMatch) {
      $this -> queries[$this -> originalQueryAmount] = new CSSQuery (
        $queryMatch,
        $this,
        $this -> originalQueryAmount
      );
      $this -> originalQueryAmount++;
    }
  }
  
  public function removeQuery(CSSQuery $query) {
    unset($this -> queries[$query -> queryKey]);
  }
  
  public function __destruct() {
    
  }
}
class CSSRuleSet {
  public $selector;
  public $declarations;
  
  public $document;
  
  public function __construct (
    CSSSelector $selector,
    $declarations,
    CSSDocument $document
  ) {
    $this -> selector = $selector;
    $this -> selector -> ruleSet = $this;
    
    $this -> declarations = $declarations;
    foreach($this -> declarations as $declaration) {
      $declaration -> ruleSet = $this;
    }
  }
  
  public function removeDeclaration(CSSDeclaration $declarationToRemove) {
    foreach($this -> declarations as $iDeclaration => $declaration) {
      if (
        $declaration -> property === $declarationToRemove -> property
        &&
        $declaration -> value === $declarationToRemove -> value
      ) {
        unset($this -> declarations[$iDeclaration]);
      }
    }
  }
  
  public function __destruct() {
    
  }
}
class CSSAtRule {
  public $selector;
  public $ruleSets;
  
  public function __construct (
    CSSSelector $selector,
    $ruleSets
  ) {
    $this -> selector = $selector;
    $this -> ruleSets = $ruleSets;
  }
}

class CSSDocument {
  public $atRules = [];
  
  private $currentAtRuleSets;
  
  public function __construct (
    $source
  ) {
    $source = '@media all{' . preg_replace (
      [
        '/@(keyframes|media|supports)/',
        '/}}@font-face/',
        '/}}([^@])/'
      ],
      [
        '}@$1',
        '}}@media all{@font-face',
        '}}@media all{$1'
      ],
      $source
    ) . '}';
    $source = str_replace('@media all{}', '', $source);
    
    preg_replace_callback (
      '/@(keyframes|media|supports)([^{}]+){(.+})}/sU',
      function($atRuleMatch) {
        $this -> currentAtRuleSets = [];
        
        $atRuleRuleSetsMatch = $atRuleMatch[3];
        
        preg_replace_callback (
          [
            '/([^{}]+){([^{}]*)}/m'
          ],
          function($ruleSet) {
            
            $selector = $ruleSet[1];
            
            $declarationBlock = $ruleSet[2];
            $declarations = [];
            
            preg_match_all (
              '/([^:;{}]+)[:]([^:;}{]+)/',
              $declarationBlock,
              $declarationsMatch,
              PREG_SET_ORDER
            );
            $declarationsMatch = $declarationsMatch;
            
            foreach($declarationsMatch as $declarationMatch) {
              
              array_push (
                $declarations,
                new CSSDeclaration (
                  $declarationMatch[1],
                  $declarationMatch[2]
                )
              );
            }
            
            array_push (
              $this -> currentAtRuleSets,
              new CSSRuleSet (
                new CSSSelector (
                  $selector
                ),
                $declarations,
                $this
              )
            );
          },
          $atRuleRuleSetsMatch
        );
        
        array_push (
          $this -> atRules,
          new CSSAtRule (
            new CSSSelector('@' . $atRuleMatch[1] . $atRuleMatch[2]),
            $this -> currentAtRuleSets
          )
        );
        
      },
      $source
    );
  }
  
  public function save() {
    $css = '';
    
    foreach($this -> atRules as $atRule) {
      $iAtQuery = 0;
      foreach($atRule -> selector -> queries as $iAtQuery => $query) {
        if($query -> queryString == '@media all') {
          unset($atRule -> selector -> queries[$iAtQuery]);
        }
      }
      $amountOfAtQueries = count($atRule -> selector -> queries);
      
      if($amountOfAtQueries > 0) {
        foreach($atRule -> selector -> queries as $query) {
          $iAtQuery++;
          $css .= $query -> queryString;
          if($iAtQuery < $amountOfAtQueries) {
            $css .= ',';
          }
        }
        $css .= '{';
      }
      
      foreach($atRule -> ruleSets as $ruleSet) {
        $amountOfQueries = count($ruleSet -> selector -> queries);
        if($amountOfQueries > 0) {
          $iQuery = 0;
          foreach($ruleSet -> selector -> queries as $query) {
            $iQuery++;
            $css .= $query -> queryString;
            if($iQuery < $amountOfQueries) {
              $css .= ',';
            }
          }
          
          $css .= '{';
          $amountOfDeclarations = count($ruleSet -> declarations);
          $iDeclaration = 0;
          foreach($ruleSet -> declarations as $declaration) {
            $iDeclaration++;
            $css .= $declaration -> property . ':' . $declaration -> value;
            if($iDeclaration < $amountOfDeclarations) {
              $css .= ';';
            }
          }
          $css .= '}';
        }
      }
      
      if($amountOfAtQueries > 0) {
        $css .= '}';
      }
    }
    
    return $css;
  }
  
  public function removeUnusedClasses($usedClasses, $whiteListedClasses = [], $stateClassesByDefaultState = []) {
    foreach($stateClassesByDefaultState as $defaultState => $stateClasses) {
      if (
        in_array($defaultState, $usedClasses)
        ||
        in_array($defaultState, $whiteListedClasses)
      ) {
        array_push (
          $whiteListedClasses,
          ...$stateClasses
        );
      }
    }
    
    foreach($this -> atRules as $atRule) {
      foreach($atRule -> ruleSets as $ruleSet) {
        foreach($ruleSet -> selector -> queries as $query) {
          $queryStringWithoutNegation = preg_replace (
            '/:not(.[^0123456789 #.([{}]):>+~*,][^ #.([{}]):>+~*,]+)/',
            '',
            $query -> queryString
          );
          
          preg_match_all (
            '/.([^0123456789 #.([{}]):>+~*,][^ #.([{}]):>+~*,]+)/',
            $queryStringWithoutNegation,
            $classesInQueryWithoutNegation
          );
          
          $queryRemoved = false;
          
          foreach($classesInQueryWithoutNegation[1] as $class) {
            if (
              !in_array($class, $usedClasses)
              &&
              !in_array($class, $whiteListedClasses)
            ) {
              $query -> selector -> removeQuery($query);
              $queryRemoved = true;
              break;
            }
          }
          
          if(!$queryRemoved) {
            $query -> queryString = preg_replace_callback (
              '/.([^0123456789 #.([{}]):>+~*,][^ #.([{}]):>+~*,]+)/',
              function($class) use ($usedClasses, $whiteListedClasses) {
                if (
                  !in_array($class[1], $usedClasses)
                  &&
                  !in_array($class[1], $whiteListedClasses)
                ) {
                  return '.__';
                } else {
                  return $class[0];
                }
              },
              $query -> queryString
            );
          }
        }
      }
    }
  }
}

The above code consist only of PHP classes, so there is no object constructed yet.

Note: this code does not support nested at-rules (@media, @supports, @keyframes).

In order to use this functionality, you create a new CSSDocument object using the collected CSS code as the argument.

$cssDocument = new CSSDocument($css);

This object carries a function called removeUnusedClasses. That’s my favorite function so far, what about you?

Whitelisting classes indicating non-default states

Some classes indicate a non-default state for certain elements, for example:

  • Hamburger menus / expandable menus may or may not be expanded.
  • An element’s animation may or may not yet be activated.

These non-default states are indicated by classes that might not be present when saving a page, and thus may not be present in the JSON files for keeping track of what classes are used.

Such classes, expanded for example, are often tied to other classes, for instance: hamburger-menu.

In the example below I’ve whitelisted class expanded for when class hamburger-menu is used.

$stateClassesByDefaultState = [
  'hamburger-menu' => [
    'expanded'
  ],
  'anim-fade-in' => [
    'activated'
  ]
];

Moment of truth: remove unused CSS classes

Once you’ve whitelisted some classes, you can now use the two JSON files containing used classes as arguments for function removeUnusedClasses:

  • classes-by-pages.json
  • classes-by-templates.json

The array of classes to whitelist when certain classes are used, $stateClassesByDefaultState, is used as the third and last argument for the said function call. Here we go:

$cssDocument -> removeUnusedClasses (
  $classesByPages,
  $classesByTemplates,
  $stateClassesByDefaultState
);

$optimizedCSS = $cssDocument -> save();
echo $optimizedCSS;

Fine tuning the process of removing unused CSS

There is some fine tuning to be done in order to, for instance, make sure that the list of used classes won’t get updated when not necessary.

Excluding certain classes from list of used classes

Some classes should not get added to the list of classes, as they are either random, or they are both unused and specific to the page (ID), etc.

  • Randomized classes, as they change often and are not used (probably).
  • Classes that are specific to the page, like the page id: page-id-9.
  • Classes that indicate a certain state, because they will get whitelisted anyways if the class that indicates it’s default state is present. I’ve covered this earlier.

Page-specific and randomized classes

Predictable page-specific and randomized classes can be easily excluded from being added to the list of used classes. You can just write a regular expression, like this:

if(!elClass.match(/post-(d)+/g)) {
  // class didn't match, you can add the class to the list of used classes
}

The example above excludes all classes that start with post- and end with digits.

Classes that indicate a non-default state

I have discussed these kind of classes earlier. However, what I haven’t told you is that there is no need to add these classes to the list of used classes, as they will get whitelisted anyways – if the class indicating it’s default state is present.

if(!['expanded', 'activated'].includes(elClass)) {
  // add class to list of used classes
}

Tracking classes added by JavaScript

If some classes are added by JavaScript files, you should just whitelist them by calling addClasses and providing the path to the JavaScript file for the 3rd argument.

addClasses (
  'some-class another-class and-another-one',
  false,
  '/some-javascript/file.js'
);

You can also directly add them to the array of classes added by templates, like this:

$classesAddedByTemplates['/some/javascript/file.js'] = [
  'some-class',
  'another-class',
  'and-another-one'
];

Denying public access to the JSON files

Since nobody has to read the JSON files, you might as well make them not publicly available. You can do this by adding a .htaccess file and the JSON files in a seperate folder. Make sure to modify the file paths in your PHP code. Contents of .htaccess file:

deny from all

The code showcased in this article is used on this website to remove unused CSS classes. It took me a lot of time and effort to make it work. If you happen to like articles or unique solutions like these, be sure to stick around for more blog posts about CSS.