CSSTidy CSS Formatter

CSS Minification Gotchas: 22 Inputs, Four Minifiers, Real Output

CSSTidy — CSS Formatter & Minifier Guides · Updated 2026-10-11 · All guides

How to re-run this

Nothing here is remembered from documentation. Each case below went through the actual shipped minifyCss and beautifyCss in apps/csstidy/public/index.html, loaded under Node without forking the code, against freshly installed [email protected], [email protected], [email protected], and [email protected].

cd /home/walshie/code/sites/dev-utilities/scripts/fuzz
node -e 'const {loadApp}=require("./load");
  const api=loadApp("csstidy",["beautifyCss","minifyCss"]).api;
  console.log(JSON.stringify(api.runHandler(api.minifyCss,"a { color: red; }")))'
# → "a{color:red}"

runHandler sets #in, calls the real click handler the button calls, and returns what it wrote to #out. That is the user path, not a copy of the function.

The shipped minifier is four regexes and a trim:

text.replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/\s*([\{\};:,])\s*/g, "$1")
    .replace(/;\}/g, "}")
    .trim();

That is the whole thing. Read it before you read my results — most of the surprises are already visible in those four lines.

The results

— means the tool produced output I would not ship. Byte-exact output, JSON.stringify-quoted, so trailing whitespace is visible.

Inputcsstidy (shipped)cssnano 9.6.0Lightning CSS 1.33.0CSSO 5.0.5
/*! keep me */\na{color:red}a{color:red}/*! keep me */a{color:red}/*! keep me */\na{color:red}/*! keep me */\na{color:red}
@media (min-width: 600px) { .a { color: red; } .b { color: blue; } }@media (min-width:600px){.a{color:red}.b{color:blue}}@media(min-width:600px){.a{color:red}.b{color:blue}}@media (width>=600px){.a{color:red}.b{color:#00f}}@media (min-width:600px){.a{color:red}.b{color:#00f}}
a{color:red}\n@import url("b.css");a{color:red}@import url("b.css");a{color:red}@import "b.css";a{color:red}a{color:red}
a{background:url( my image.png )}a{background:url( my image.png )}a{background:url( my image.png )}throws SyntaxError: Unexpected token BadUrl("my image.png "); with errorRecovery → ""a{background:url( my image.png )}
a{width:calc(100% - 20px);margin:calc(1px + 2px)}unchanged…;margin:calc(3px)}…;margin:3px}unchanged
a{*zoom:1;color:red}a{*zoom:1;color:red}a{*zoom:1;color:red}a{color:red}a{*zoom:1;color:red}
a{_height:20px}a{_height:20px}a{_height:20px}a{_height:20px}a{_height:20px}
a{-webkit-box-shadow:0 0 2px #000;box-shadow:…}identical to inputidenticalidenticalidentical
a{}b{color:red}a{}b{color:red}b{color:red}b{color:red}b{color:red}
a{color:red;}a{color:red}a{color:red}a{color:red}a{color:red}
a{color:red;;}a{color:red;}a{color:red}a{color:red}a{color:red}
a{color:#ff0000;background:#ffffff}unchangeda{color:red;background:#fff}a{color:red;background:#fff}a{color:red;background:#fff}
a{margin:0px;padding:0em;border-width:0}unchangeda{margin:0;padding:0;border-width:0}a{border-width:0;margin:0;padding:0}a{margin:0;padding:0;border-width:0}
a , b , c { color : red }a,b,c{color:red}samesamesame
a{color:var(--x, red)}a{color:var(--x,red)}a{color:var(--x,red)}a{color:var(--x,red)}a{color:var(--x, red)}
a{color:var(--a, var(--b, blue))}a{color:var(--a,var(--b,blue))}samesamea{color:var(--a, var(--b, blue))}
div::before{content:"}"}div::before{content:"}"}div:before{content:"}"}div:before{content:"}"}div::before{content:"}"}
div::before{content:"a;b"}div::before{content:"a;b"}div:before{content:"a;b"}div:before{content:"a;b"}div::before{content:"a;b"}
a{content:"x/*y*/z"}a{content:"xz"}a{content:"x/*y*/z"}a{content:"x/*y*/z"}a{content:"x/*y*/z"}
a{content:"12:30"}a{content:"12:30"}samesamesame
a{margin: 10px 20px}a{margin:10px 20px}a{margin:10px 20px}a{margin:10px 20px}a{margin:10px 20px}
a{background:url(data:image/png;base64,AAA=)}a{background:url(data:image/png;base64,AAA=)}a{background:url(data:image/png;base64,AAA=)}a{background:url(data:image/png;base64,AAA=)}a{background:url(data:image/png;base64,AAA=)}

Note the last row: every tool, including this one, leaves a data: URI alone when minifying. The breakage in that input is beautifier-only — see the beautifier section.

Five where the shipped tool is wrong, plainly

1. It deletes licence headers. /*! … */ is the browser-ignored-but-legally-significant comment convention. The stripper regex has no ! branch, so /*! Copyright 2026 */ disappears. cssnano, Lightning CSS, and CSSO all kept it, on every run. If you ship third-party CSS through this page, you have stripped its licence text. This is the one case on the page where I would call the tool unsafe for its advertised job.

2. It eats characters inside strings. a{content:"x/*y*/z"} → a{content:"xz"}. The comment regex does not know what a string is, so data becomes a comment and is deleted. Every parser on this page tokenises strings first and returned the input unchanged. This is a silent output-correctness bug, not a size miss.

3. It never collapses whitespace inside a value. margin: 10px 20px stays as three spaces, because the collapse regex only fires adjacent to { } ; : ,. Real minifiers normalise value-internal whitespace, so this tool's byte count is a floor you cannot reach with it.

4. It cannot shorten anything. #ff0000 → #ff0000, 0px → 0px. Not a bug — by design, per About — but it means a size comparison against cssnano is not a fair fight. Measured below.

5. It is invalid-preserving rather than invalid-fixing. A misplaced @import after a rule stays. Lightning CSS and CSSO both discard it, which is what a browser does (CSS 2.1 §6.3: @import must precede all other rules). So the textual tool gives you a file that looks like it kept your import and was already not loading it; the parsers give you a file that tells you the truth. Neither is "fixed" — but only one of them surfaces the problem.

Three where the parsers will surprise you

These are not arguments for the textual tool. They are reasons to diff output rather than trust either side.

  • Lightning CSS rewrites syntax you wrote. (min-width: 600px) came back as (width>=600px), blue as #00f, and declarations came out reordered (border-width before margin before padding). All valid, all semantically equivalent, all guaranteed to blow up your git diff on the first run.
  • Lightning CSS rejects url( my image.png ) outright and, worse for CI, returns an empty string under errorRecovery: true rather than the unminified input. A recovery mode that yields "" is how a deploy ships a blank stylesheet.
  • Lightning CSS drops *zoom:1. Correct for modern targets, wrong for the legacy IE branch it was written for. cssnano and CSSO kept it.

Size, on a real file

[email protected] from unpkg, 95,374 bytes, single Node process, my machine (Linux arm64, Node v24.21.0), one run:

ToolTimeOutput bytesReduction
csstidy (shipped, textual)1.1 ms75,76820.6%
cssnano 9.6.0123.9 ms72,73023.7%
CSSO 5.0.579.9 ms73,22023.2%
Lightning CSS 1.33.03.7 ms68,83727.8%

Two things worth noticing. The gap between a minifier that only removes whitespace and one that parses is roughly 7 percentage points on this file — smaller than most write-ups imply, larger than "just whitespace" implies. And cssnano took 112× longer than Lightning CSS to produce more bytes. That ordering is the vendor's own claim too (below), so treat it as consistent, not as my discovery.

The 1.1 ms figure is one process, one file, one pass — a warm-JIT single measurement, not a benchmark. To reproduce:

cd /tmp/csscompare && node bench.js   # installs: postcss cssnano lightningcss csso

Beautifier-only failures the minifier does not share

Worth stating separately because About documents one of these and the pattern is wider than that one case. The beautifier inserts \n at every ; and \n}\n\n at every }, with no string or comment awareness:

Inputbeautify output
div::before{content:"}"}div::before {\n content:"\n}\n\n"\n}
div::before{content:"a;b"}div::before {\n content:"a;\n b"\n}
a{background:url(data:image/png;base64,AAA=)}a {\n background:url(data:image/png;\n base64,AAA=)\n}

The third one is the expensive one. Inline data: URIs — SVG icons, small fonts — are extremely common and commonly contain ;, and beautifying one splits it across two declarations. The minifier leaves all three inputs intact; only the beautifier breaks them. If you paste a component's CSS in here to read it, and it has an inline SVG, the readable version is a corrupted version. Diff it, or use a formatter with a tokenizer.

The idempotency result I checked because I was unsure

beautify(beautify(x)) == beautify(x) held on the media-query case (true), and minify(beautify(x)) == minify(x) held on .a{color:red;padding:0}. So the loop most people fear — paste, format, paste back, format again — is not where this tool loses data.

Strings are, and the way they lose it is worse than a failed round-trip. I expected content:"}" to break both checks. It broke neither. Both returned true:

Inputminify(beautify(x)) == minify(x)beautify(beautify(x)) == beautify(x)
div::before{content:"}"}truetrue
div::before{content:"a;b"}truetrue
a{background:url(data:image/png;base64,AAA=)}truetrue

That is not a pass. minify removes whitespace on either side of every ;, }, :, and , — which is precisely the whitespace beautify just inserted, including the whitespace inside the string. So re-minifying re-glues the split data: URI back into one declaration and the test sees agreement. The damage is real in the output you are reading and invisible to the check you would run afterwards.

The practical consequence: if your workflow is beautify-to-read then minify-to-ship, you are safe by accident. If you ever commit, copy, or paste the beautified text — into a PR, a bug report, a component file — you are shipping a corrupted content: string or a truncated data: URI, and no round-trip test will tell you.

Attribution for the speed numbers I did not measure

The 544.809ms cssnano / 17.199ms esbuild / 4.16ms lightningcss figures quoted around this project are the Lightning CSS project's own benchmark, from their README, run on bootstrap-4.css: <https://raw.githubusercontent.com/parcel-bundler/lightningcss/master/README.md> (pulled 2026-10-11). Their animate.css line is cssnano: 283.105ms vs lightningcss: 1.973ms. I did not reproduce those exact numbers and would not quote them as mine. My table above is my single-run measurement on a different file, and it says the same direction with different magnitudes.

What I would actually do

  • Reading a snippet: this page is fine, and faster than installing anything. Check any content: string and any data: URI afterwards.
  • Shipping a stylesheet: use a parser, and diff the output once before you trust the pipeline. Not because parsers are infallible — the errorRecovery: "" case above is exactly a parser failing badly — but because a parser fails loudly and a regex fails silently.
  • Keeping a licence header: never minify through a tool that has no /*! branch. That rules out the tool hosting this page.
Developer Sponsor / Partner
Copied to clipboard!