CSS Portal

Unused CSS Finder - Command Line Version

Stylesheets grow. Features get removed, templates change, and the CSS that styled them stays behind. Every extra byte is downloaded by every visitor and has to be read by every developer who touches the file.

The online Unused CSS Finder checks one page at a time. This command-line version is built for whole websites: it crawls your site the way a visitor would, collects every class and id on every page it finds, and tells you which selectors in your stylesheets can never match anything.

Jump to: Quick start · Report viewer · Options · Source files · Safelisting · Reading the report · What is checked · What is not checked · Troubleshooting · FAQ

☕
Enjoying this site?

Help keep it running with a small coffee donation. Every bit of support is appreciated!

Buy us a coffee
Download and quick start
  • PHP 7.1 or newerNeeds the curl and dom extensions
  • Command line onlyRuns on your own computer or server
  • Reads, never writesOnly fetches pages; your site is not changed
  • Polite crawlerFollows robots.txt, adjustable speed

Before you begin

  • PHP 7.1 or newer installed for the command line. Check with php -v.
  • The curl, dom and libxml PHP extensions enabled. They are on by default in most installs. The script tells you if one is missing.
  • Permission to scan the site. Only scan sites you own or are allowed to test.
  1. Download and unzip. Save unused_css.php anywhere outside your public web folder, for example a tools folder.
  2. Open a terminal in that folder.

    On Windows, open Command Prompt or PowerShell. If php is not recognised, use the full path to php.exe (for WAMP this is similar to C:\wamp64\bin\php\php8.2.0\php.exe; the version number will differ).

  3. Run it with your site's address.
    macOS / Linux
    php unused_css.php https://example.com/
    Windows
    php unused_css.php https://example.com/
    
    REM or with the full path to PHP
    C:\wamp64\bin\php\php8.2.0\php.exe unused_css.php https://example.com/
  4. Open the report. When it finishes, look in the new unused-css-example.com folder next to where you ran the command, and open report.txt.

Common ways to run it

# Also write cleaned copies of your stylesheets
php unused_css.php https://example.com/ --purge

# Keep any class starting with "btn-" or named "modal", and skip the blog
php unused_css.php https://example.com/ --safelist="btn-*" --safelist=modal --exclude=/blog/

# Go gently on a small server
php unused_css.php https://example.com/ --concurrency=1 --delay=500

# Use a saved settings file
php unused_css.php --config=unused-css.json

# Check the website files in a folder, with no crawl (reaches logins and members areas)
php unused_css.php --source=/var/www/example

# Crawl the live site and read the source files too (the most complete check)
php unused_css.php https://example.com/ --source=/var/www/example

The crawl saves its progress as it goes. If it is interrupted, run the same command again and it picks up where it stopped. Add --fresh to start over.

☕
Enjoying this site?

Help keep it running with a small coffee donation. Every bit of support is appreciated!

Buy us a coffee
About the command-line version

A site-wide tool for cleaner stylesheets

How it works

This script finds that dead weight. It works in three steps:

  1. Crawl. Starting from the address you give it, it follows internal links and reads each page, recording every class, every id and every stylesheet and script the page uses.
  2. Read the CSS. It downloads each stylesheet (including @imported ones and inline <style> blocks) and splits it into individual selectors.
  3. Compare. A selector is reported as unused when it needs a class or id that appears nowhere on the site, in any script, or in your safelist.
The idea in one line

It is a careful estimate, not a proof. It reports what no crawled page uses, so always read what is not checked and review the results before deleting any CSS.

What you get

FileContents
report.txtReadable summary: warnings, then unused classes and ids for each stylesheet, then totals.
report.jsonThe same data for other tools, plus every selector that would be removed and any pages that failed.
purged/*.cssOnly with --purge. Cleaned copies of each stylesheet. Your originals are never touched.

Options

Only the address is required. Everything else has a sensible default. Options can be written --name=value or --name value.

OptionDefaultWhat it does
<url>required, unless --source is usedThe page to start from, usually your home page. https:// is assumed if you leave it off.
--output=DIR./unused-css-<host>Folder for the reports and saved progress.
--purgeoffAlso write cleaned copies of every stylesheet to DIR/purged/.
--max-pages=N5000Stop after N pages. 0 means no limit. If the limit is hit, the report carries a warning.
--concurrency=N4Pages fetched at the same time (1–20).
--delay=MS0Minimum pause between requests, in milliseconds. A Crawl-delay in robots.txt is applied automatically (up to 10 seconds).
--timeout=SEC20How long to wait for a single request (1–120).
--keep-queryoffTreat /page?a=1 and /page?a=2 as different pages. By default query strings are ignored, which is faster and almost always gives the same result.
--exclude=PATTERNnoneDo not crawl URLs that match. Can be repeated.
--safelist=PATTERNnoneAlways keep matching class names. Can be repeated.
--safelist-id=PATTERNnoneAlways keep matching ids. Can be repeated.
--no-default-safelistoffStop automatically keeping active, show, open, hidden, visible, disabled, selected, fade, collapse, collapsing and anything starting with is-, has- or js-.
--no-js-prefixesoffStop treating strings such as 'btn-' in scripts as a reason to keep every class that begins with them.
--external-cssoffAlso analyse stylesheets hosted on other domains, such as a CDN. Usually not what you want.
--external-jsoffAlso read scripts hosted on other domains for class names.
--no-robotsoffIgnore robots.txt. Only use this on a site you own.
--insecureoffSkip SSL certificate checks. Use only for a site you trust, such as a test server with a self-signed certificate.
--user-agent=TEXTUnusedCssFinder/1.0The name the crawler sends to your server.
--source=DIRnoneRead the source files in this folder for class and id names. Can be repeated. With no web address the .css files in the folder are checked too. More
--source-ext=LISThtml, htm, php, js, vue and moreThe file types to read, separated by commas, for example --source-ext=php,html,js. Replaces the default list.
--source-exclude=PATTERNnoneSkip source paths that match, measured from the source folder and written with forward slashes (/tests/, .min.js). Can be repeated.
--no-default-source-excludesoffAlso read node_modules, vendor, bower_components, .git, .svn, .hg, .idea and .vscode, which are skipped by default.
--local-cssoffWith a web address, also check the .css files found in the source folders. Not needed without a web address, where it is automatic.
--include-unlinked-cssoffCheck every .css file in the source folders. By default only the ones a source file refers to are checked.
--freshoffIgnore any saved progress and crawl again from the start.
--config=FILEnoneRead options from a JSON file. Anything typed on the command line wins.
--help, --versionShow the built-in help or the version.

Settings file

For a site you scan regularly, keep the settings in a JSON file. Use the option names without the leading dashes:

{
  "url": "https://example.com/",
  "max-pages": 2000,
  "delay": 250,
  "exclude": ["/blog/", "/forum/*"],
  "safelist": ["btn-*", "modal", "re:^col-(sm|md|lg)-\\d+$"],
  "safelist-id": ["cookie-banner"],
  "source": ["/var/www/example"],
  "source-exclude": ["/tests/", ".min.js"],
  "purge": true
}

Then run php unused_css.php --config=unused-css.json.

Exit codes

0 means the run completed. 1 means it could not start or finish (bad option, unreachable site, no pages readable). Warnings about an incomplete crawl do not change the exit code, so check the report.

Checking your source files

A crawler can only see pages a visitor can reach. Members areas, logged-in dashboards, checkout steps and error screens are out of its reach. If you have the website's files, point the tool at the folder with --source. It reads your templates and scripts directly, so those areas are covered.

You runWhat it checks
--source=DIRReads the source files in DIR and checks the .css files in the same folder tree that your source files refer to. No website needed, nothing is fetched.
<url> --source=DIRCrawls the live site and reads the source files. A class counts as used if either one finds it. The stylesheets checked are the ones the site serves. This is the most complete check.
<url> --source=DIR --local-cssThe same, and also checks the .css files found in DIR.
# Source files only
php unused_css.php --source=/var/www/example

# Only some file types, and skip tests and minified scripts
php unused_css.php --source=/var/www/example --source-ext=php,html,js --source-exclude=/tests/ --source-exclude=.min.js

# Crawl the live site and read the files too
php unused_css.php https://example.com/ --source=/var/www/example

On Windows write the folder as --source="C:\wamp64\www\example".

How source files are read

  • Every word counts as use. The tool does not understand PHP, Twig or JavaScript. It collects every word in each file, so class="members-only", classList.add('is-open') and echo 'btn-' . $type all keep their classes. A prefix such as 'btn-' keeps every class that begins with it, as it does for scripts on a crawl.
  • Default file types: html, htm, xhtml, php, phtml, inc, twig, tpl, js, mjs, jsx, ts, tsx, vue, svelte, erb, ejs, hbs, handlebars, njk, liquid, asp, aspx, cshtml, jsp and md. Use --source-ext to choose your own. Stylesheets are never read as source.
  • Skipped folders: node_modules, vendor, bower_components, .git, .svn, .hg, .idea and .vscode. Third-party code in them would otherwise make almost every class look used. Binary files and files over 20 MB are skipped, and the report says how many.
  • Only linked stylesheets are checked. A .css file is checked when a source file mentions it, for example in a <link href="css/main.css?v=3">, an @import, or a script or wp_enqueue_style call. Files imported by a checked stylesheet are followed too. Spare, old or backup stylesheets that nothing refers to are left out, and the report lists them so you can see which ones they are. Use --include-unlinked-css to check them anyway. If no source file mentions a stylesheet at all (for example a name built from variables), every .css file is checked, with a warning.
  • Reading the report. The report lists how many source files were read and which stylesheets were not checked (unlinkedCss in report.json). pagesRead is 0 when no site was crawled.
Be aware

Because every word counts, this method errs on the side of keeping CSS. A class called header is kept if the word "header" appears anywhere in your source, even in a comment or a variable name. Expect it to find less unused CSS than a crawl, but to be safer for areas the crawler cannot see. Third-party scripts and test files can add words too. Skip them with --source-exclude, for example --source-exclude=.min.js.

Tips

  • For a site that has a build step (Sass, a bundler), point --source at the folder with your templates and crawl the built site for its CSS, rather than checking the source .css files.
  • Stylesheets are matched by path ending, so css/main.css in your source matches main.css anywhere in the folder tree. A web address such as a CDN link is never matched to a local file.
  • Without a crawl, @import lines are followed when the file is inside the source folder. Web addresses in an @import are skipped.
  • Classes added only by a server script outside the folders you gave are not seen. Add all the folders that hold your markup, with more than one --source if needed.

Safelisting

A safelist tells the tool “keep this, even if you cannot see it being used.” You need one when classes appear only at certain moments or are built in ways a crawler cannot read.

Pattern syntax

You writeMeaning
modalExactly modal.
btn-*Anything starting with btn-.
*-iconAnything ending with -icon.
re:^col-(sm|md)-\d+$Anything starting with re: is a regular expression. For a case-insensitive match write re:(?i)^promo.

--safelist and --safelist-id match the whole name. --exclude matches anywhere in the URL, so --exclude=/blog/ skips every address containing /blog/.

On most terminals, put patterns containing *, (, | or $ in quotes so the shell does not change them.

When to safelist

  • Classes added by clicks or timers in another way than plain strings, for example el.className = prefix + name.
  • Classes that only exist in situations the crawler cannot reach: error messages, logged-in screens, seasonal banners, A/B variants.
  • Classes used by third-party widgets that inject their own markup.

Reading the report

The easiest way to read the results is the report viewer: drop your report.json onto it for a sorted, searchable summary, with an option to hide stylesheets that have nothing to remove. Everything stays in your browser. The plain-text version, report.txt, looks like this (trimmed):

Unused CSS report for https://example.com/
============================================================
Generated 2026-10-14T00:12:41+00:00 by unused_css.php 1.1.2
Pages read: 214

WARNINGS
  - 3 page(s) could not be read (errors, timeouts or blocked requests).
    Classes used only on those pages may be wrongly listed as unused.

https://example.com/css/site.css?v=14
  selectors: 1,204 | unused: 311 | size: 88,412 -> 61,980 bytes
  unused classes: .promo-old .legacy-nav .tab-3 ...
  unused ids: #old-banner

UNUSED CLASSES (96)
  .legacy-nav
  .promo-old
  ...
  • Warnings come first. If the crawl was cut short or pages failed, treat the list as a set of candidates, not facts.
  • selectors / unused. A selector is one entry in a comma list. .a, .b { } is two selectors; if only .b is unused, .a stays in the cleaned file.
  • size before → after. Applies only to the cleaned copy that --purge writes.
  • report.json lists every individual selector that would be removed, which is useful when checking a surprising result.
About the cleaned files

The files in purged/ are re-written, not minified. Comments are removed and each selector goes on its own line. Rules are kept in their original order. They are meant for review and testing, not as a drop-in replacement for your live CSS.

What is checked

Counted as “used”

  • Every class in a class="…" attribute on any crawled page.
  • Every id="…".
  • Ids referenced by for, aria-controls, aria-labelledby, aria-describedby, aria-owns, list and form attributes.
  • Ids targeted by a link such as <a href="#team"> or /page#team.
  • Every word in inline scripts, in your own .js files, and in data-* attributes. That covers classList.add('is-open'), jQuery selectors and data-bs-target="#menu".
  • Strings ending in - or _ in scripts (for example 'btn-' + type) keep every class that starts with them.
  • Anything on your safelist, plus the default safelist of common state classes.

Where it looks for CSS

  • <link rel="stylesheet"> and <link rel="preload" as="style">
  • Inline <style> blocks
  • @import chains, followed automatically
  • Rules inside @media, @supports, @layer, @container, @document and @scope

Crawling behaviour

  • Follows <a href> links on the same site. example.com and www.example.com count as one site, and a start page that redirects (http→https, or to www) is followed.
  • Respects Disallow rules for User-agent: * and any Crawl-delay.
  • Scans pages marked noindex or with a different canonical address, because their CSS is still in use.
  • Reads non-English class names correctly (UTF-8).

What is not checked

These are the limits of the approach. Some make the tool flag classes that are really in use, and those are the ones to review before you delete anything. Others make it keep classes that are really unused, which is safe but means you may see smaller savings. The rest are things it simply does not analyse.

Things that can make a used class look unused

Review these first

If a class you know is in use appears in the list, one of the reasons below is almost certainly why. Add it to the safelist.

  • JavaScript is not run. The tool reads the HTML the server sends. Content that scripts build in the browser, such as single-page apps (React, Vue, Angular), content loaded with fetch or AJAX, infinite scroll, or markup injected from a template, is invisible to it. For such sites the results are unreliable.
  • Pages it cannot reach. Only links in the HTML are followed. It does not read your sitemap, log in, keep cookies, submit forms or use search. Orphan pages, member areas, checkout steps, search results and pages that exist only behind JavaScript menus are never seen. If you have the website's files, read them with --source to cover these areas.
  • States that are not showing. Error messages, success banners, flash notices, cookie banners shown only once, seasonal promotions, A/B variants and per-language pages that are not linked are missing unless they happened to be in the HTML it fetched.
  • Class names built in pieces. A prefix such as 'btn-' + type is detected. A suffix (type + '-btn') or a name assembled from several parts in the middle is not.
  • Server-side only differences. If your server sends different HTML to crawlers than to browsers, or blocks the crawler partway, the tool sees only what it was given. Check the warnings for failed pages.
  • Crawl limits. If --max-pages is reached, pages beyond it are not scanned. The report warns you when this happens.
  • Other hosts. Subdomains other than www are treated as different sites and are not crawled. Third-party CSS and JavaScript are skipped unless you opt in.
  • Size limits. Pages over 5 MB and stylesheets or scripts over 20 MB are skipped. Up to five redirects are followed.

Things that can make an unused class look used

  • Words in scripts count. Every word in your JavaScript counts as use, so a class named header is kept if the word “header” appears in any script, even as a variable name. This is deliberate: it avoids deleting classes that scripts really do add.
  • Prefix matching is broad. One string such as 'col-' in a script keeps every class starting with col-. Use --no-js-prefixes if this keeps too much.
  • Source files. With --source, every word in your templates and scripts counts as use, comments and variable names included. See checking your source files.
  • The default safelist. Classes such as active and is-* are kept even if nothing uses them. Use --no-default-safelist to turn this off.

What the CSS check does not do

  • It does not match selectors against the page structure. A rule like .menu .item is kept if both classes appear anywhere on the site, even if no .item ever sits inside a .menu. Descendant, child and sibling relationships are not tested.
  • Only classes and ids are examined. Tag selectors, attribute selectors ([type=text], [class^="icon-"]), pseudo-classes and pseudo-elements are not checked. A selector with no class or id in it is always kept.
  • Arguments of :not(), :is(), :where() and :has() are ignored. A class used only inside one of those never causes a rule to be flagged.
  • Native CSS nesting is not analysed. The outer rule is checked; rules nested inside its braces are kept untouched.
  • Other unused CSS is left alone. It does not remove unused @keyframes, @font-face, @property, custom properties (--my-color) or declarations inside a rule.
  • It does not check whether a rule has any effect. A rule that is always overridden by another still counts as used if its classes are on a page.
  • Inline style="" attributes are not part of the comparison.

A safe workflow

  1. Run it and read the warnings. If pages failed or the page limit was reached, fix that first (raise --max-pages, lower --concurrency, add --delay).
  2. Scan the list for things you recognise. Anything you know is used goes on the safelist. Pay attention to modals, menus, forms, alerts and anything JavaScript touches.
  3. Run again with your safelist. Repeat until the list holds only classes you are happy to lose.
  4. Write the cleaned files with --purge. Put them on a test copy of the site, not the live one.
  5. Click through the test site. Open menus, trigger errors, try forms and view it logged in and out, on a phone and a desktop. The crawler cannot do these things; you can.
  6. Deploy, and keep the originals. Keep a backup of the old CSS so you can roll back quickly.

Troubleshooting

What you seeWhat to do
'php' is not recognizedPHP is not on your system path. Use the full path to php.exe, or add PHP's folder to the Windows PATH setting.
Parse error: syntax error, unexpected …Your command-line PHP is older than 7.1. Run php -v. On WAMP, the command line can use a different PHP version from the one selected for the web server.
“the PHP … extension is required”Enable curl or dom in the command-line php.ini. Run php --ini to see which file that is, remove the ; in front of the extension line, and try again.
“SSL certificate problem”Your PHP has no certificate list, or the site's certificate is not trusted. Set curl.cainfo in php.ini, or for a site you trust use --insecure.
“The start page returned HTTP 403” (or 429)The site or its firewall is blocking automated requests. Allow your IP address, try --user-agent with a browser-like value, or scan a staging copy. For 429, use --concurrency=1 --delay=1000.
“Only one page was found”The site builds its pages with JavaScript, or its links are not plain <a href> tags. The tool cannot see them. Results will not be reliable.
A class I use is listed as unusedRead what is not checked, then add it to the safelist.
A class I removed long ago is not listedA word with the same spelling appears in a script, or a prefix rule keeps it. Check with --no-js-prefixes.
It takes a long timeLower --max-pages, skip sections with --exclude, or raise --concurrency if your server can take it.
It was interruptedRun the same command again. It resumes. Use --fresh to restart.
“Allowed memory size exhausted”The site is very large. Use --max-pages and --exclude to scan it in parts.

Responsible use

Only scan sites you own or have permission to test. Leave robots.txt checking on unless the site is yours, and pick a speed your server can handle. This script is released under the MIT License, so you are free to use, copy and modify it. The source code is on GitHub, where you can also report bugs and suggest improvements. It is provided as is, with no warranty. Always keep a backup of your CSS before you change anything.

Frequently Asked Questions

Will it change or break my website?

No. It only requests pages, stylesheets and scripts, the same as a visitor. It never writes to your site, and it never edits your CSS files. The cleaned copies are written next to the report, in a folder you choose.

Can I put it on my server and run it from a web page?

No. It refuses to run in a web request, on purpose. A long crawl would time out, and a public page that fetches any address you give it is a security risk. Run it from a terminal on your computer or over SSH, and keep the file outside your public web folder.

How much load does it put on my server?

By default it makes up to four requests at a time with no pause, about like a few fast visitors. On a small or shared server, use --concurrency=1 --delay=500. The tool also follows any Crawl-delay your robots.txt asks for.

Does it work with WordPress, Laravel, Shopify and so on?

It works with anything that serves normal HTML pages, because it reads the final page rather than your source code. Server-rendered sites work best. Themes that load most of their content with JavaScript will give poor results.

Can it check a members area or pages behind a login?

Not by crawling, because it does not log in. If you have the site's files, use --source=FOLDER. It reads your templates and scripts, so those pages count even though the crawler cannot reach them. See checking your source files.

Can it scan a site I run on my own computer?

Yes. Give it the local address, for example http://localhost/mysite/. If the local site uses a self-signed certificate, add --insecure.

Why does it ignore query strings by default?

Addresses like /products?page=2 and /products?page=3 normally share one template, so crawling all of them adds time but no new classes. Use --keep-query if query strings really do change the markup.

Does it send my data anywhere?

No. It only talks to the site you point it at (and, if you enable the external options, to the CDNs that site uses). Reports are written to your own disk.

Why do the cleaned files look different from my originals?

They are re-written by the tool: comments are dropped and each selector sits on its own line. The rules themselves and their order are unchanged. If you minify your CSS in a build step, run that step on the cleaned files.

☕
Enjoying this site?

Help keep it running with a small coffee donation. Every bit of support is appreciated!

Buy us a coffee