As discussed in a previous post, I have been working for a while on running Tailwind CSS without a Node.js dependency. That meant porting its CSS generation to OCaml and checking the output against the JavaScript original, byte for byte. Comparing bytes is trivial; explaining a mismatch needed a parser. The parser needed a typed representation of CSS rather than a bag of strings. Once CSS is typed, a structural diff is possible. To avoid noise, the AST first needs a canonical form, and that needs an understanding of the cascade order: a rule only moves where the cascade allows it. With CSS as a typed value, it was tempting to write each optimisation as a pure AST-to-AST rewrite and let the cascade semantics decide which of them are safe: a rewrite is allowed only where it leaves every element's resolved style unchanged. All of that in hand, writing a small CLI wrapper was the next logical step.
And so I am happy to announce that I have just released it as
cascade 1.0.0, available on
brew and on opam:
$ brew install samoht/tap/cascade # or: opam install cascade
$ cascade fmt --minify input.css > output.min.css
Cascade's merger follows the SatCSS
paper on minification by constraint
solving: it merges any rules the CSS cascade allows it to, across the
whole stylesheet, and folds values where the result is exact, calc
arithmetic and colour spellings among them. The structural diff shares
that machinery and compares two stylesheets up to semantic equivalence
under optimisation, so a cascade-safe refactor, rules regrouped,
values respelled, compares as equal. cascade apply runs the
cascade itself against an HTML page, with no browser involved, writing
each element's resolved declarations into its style attribute. And
when told the world is closed, cascade fmt will flatten @imports and
var()s away entirely.
Getting the CSS semantics right is hard
I put a handful of awkward declarations into one file and ran the
minifiers over it: cascade 1.0.0 with
--lossless --enforce-spec, csso
5.0.5, lightningcss 1.0.0-alpha.71,
esbuild 0.28.1, and
cssnano 8.0.2 driven by postcss-cli on Node
24. Here is the input:
.a { width: calc(100% / 3); }
.b { padding: 0.0000001px; }
.c { color: light-dark(white, black); }
.d { width: calc(infinity * 1px); }
.e { color: color-mix(in oklch, red 50%, blue); }
.f { .parent { color: red; & .child { color: blue; } } }
@layer reset, theme;
@layer reset { * { box-sizing: border-box; } }
.g { color: oklch(0.7 0.15 250); }
calc(100% / 3) shows up any time you size columns with calc
rather than grid-template-columns, and it is exact: fold it to a
rounded percentage and three columns no longer sum to 100%. The
0.0000001px is the kind of workaround value still in the
wild for browser
bugs. The rest are recent additions that framework CSS now
emits: modern colour functions, CSS Nesting, @layer.
lightningcss and esbuild gate their output on a browser-target list, so
running them without one would be unfair. lightningcss gets
--targets '>=0.25%', esbuild gets --target=chrome80,safari14,firefox78.
csso and cssnano have no such flag and run on their defaults.
| input | cascade (both flags) | csso | lightningcss | esbuild | cssnano |
|---|---|---|---|---|---|
calc(100% / 3) |
kept | kept | 33.3333% |
kept | 33.33333% |
0.0000001px |
kept | kept | 1e-7px |
kept | 1e-7px |
calc(infinity * 1px) |
kept | kept | 3.40282e38px |
kept | kept |
oklch(0.7 0.15 250) |
kept | kept | #4ba3f7 + lab() |
#4ba3f7 |
kept |
color-mix(in oklch, ...) |
kept | kept | pre-computed | kept | kept |
light-dark(white, black) |
names to hex | kept | to var() pair |
kept | names to hex |
& .child nesting |
kept | rule dropped | flattened | flattened | kept |
@layer reset, theme; |
kept | kept | kept | kept | kept |
Told
to support browsers down to a 0.25% share, lightningcss adds fallbacks
for syntax those browsers cannot parse: it flattens the nesting and
emits a hex colour before the lab() one. The value rewrites, the
percentage fold, the float-max constant, the scientific notation, run
unchanged at any target setting.
cssnano makes the same percentage and scientific-notation rewrites on its defaults.
csso drops the nested rule: its parser predates CSS Nesting (the last csso release is from August 2022), and a rule it cannot parse does not reach the output, with no warning.
esbuild is the most conservative here. Its one value rewrite folds
oklch(0.7 0.15 250) to hex, and only because that colour sits
inside the sRGB gamut.
Cascade parses differently: where csso drops what it cannot parse,
cascade follows the error
recovery that CSS Syntax Level 3
prescribes for browsers, discarding only what a conformant browser
would also discard and reporting a warning for each recovery (the
library API has a strict mode that turns those warnings into errors).
Its default rewrites in the same places: the in-gamut colour fold
esbuild does, and the static color-mix() lightningcss resolves, so
the default is not lossless either. (The names to hex in the table is
a respelling of the same value, which even --lossless allows.) The
difference is that one flag turns the approximations off, and what
survives the flag is written down.
The safety flags
A minifier has to answer two questions. Does this rewrite change the value? And does it assume something about the browser reading it? A browser-target list answers only the second; no tool in the table has a flag for the first. Cascade splits the two apart.
--lossless answers the first. No transform runs whose safety cannot
be argued from the CSS grammar alone: a calc() that still references
a variable is not folded, and colour channels keep their exact values.
Spellings the grammar makes identical are still shortened (white
becomes #fff, 0.5 becomes .5) and declarations are sorted into a
canonical order, because neither changes any parsed value.
--enforce-spec answers the second. By default cascade assumes an
evergreen browser and will, for instance, unwrap an @supports (display: grid) guard whose feature is now universal, or emit a media
query in the range syntax that CSS Media Queries Level 4 added.
--enforce-spec keeps the guards and the older spellings, and only
rewrites where the specification alone proves the rewrite.
They compose, and neither implies the other. --lossless alone will
still assume an evergreen baseline; --enforce-spec alone will still
approximate a colour.
The split is structural, not just a label on the CLI. The
pretty-printer is a pure serialiser: it picks the shortest spelling of
a node, never a different node, and the fuzz suite holds it to that,
printing the parsed form, reparsing, and printing again so the two
printings match byte for byte. Every rewrite that does change a node,
folding an exact calc or collapsing a colour, is a separate optimiser
pass with its own justification, so --lossless is exactly the subset
of passes whose safety comes from the grammar. When an output comes out
wrong, I can find the one pass that produced the bad node, instead of
reading a monolithic minify function end to end.
Without either flag, the default chases the shortest output: it
collapses a colour to a nearby spelling within a Delta-E budget (a
perceptual colour-difference threshold) and folds calc arithmetic
where the result is exact. The colour collapse is the approximation
--lossless drops; the exact fold changes no value and runs either
way. The other tools have knobs too, but they answer the browser
question, not the value one: cssnano ships presets, csso can switch its
restructuring off, and lightningcss and esbuild gate rewrites on a
browser-target list. I could not find an equivalent lossless contract
in any of their documented options.
The diff subcommand
cascade diff compares two stylesheets by their parsed structure
rather than their text. Take a small refactor:
/* before.css */
.note { margin: 0px; }
.btn { color: red; padding: 4px; }
.card { color: red; padding: 4px; }
/* after.css */
.note { margin: 0; border: 1px solid black; }
.btn, .card { color: red; padding: 4px; }
A line-based diff calls every line different. The structural diff
reports what changed:
$ cascade diff before.css after.css
cascade: CSS files differ
CSS: 88 chars vs 95 chars (7.4% diff)
Changes: 1 modified rule, 1 regrouped rule
--- before.css
+++ after.css
├─ .note
│ + border
│ * margin: 0px -> 0
└─ selectors merged
- .btn
- .card
+ .btn, .card
.note gained a border and had its margin respelled, while
.btn and .card were folded into one comma group with unchanged
declarations. Nothing was flagged that did not change in the text. A
--diff=canonical mode goes further and compares the two files in
cascade's canonical form, so a cascade-safe refactor
(rules regrouped, blocks moved past rules they cannot affect) counts
as no difference at all. Identical inputs exit 0 and differing inputs
exit 124, a documented code of its own, so the command slots into
pre-commit hooks and CI jobs that branch on exit codes.
The same libraries that back the CLI also back a live diff in the browser, compiled to JavaScript by js_of_ocaml and comparing CSS entirely client-side.
The apply subcommand
Everything so far treats CSS as a document: parse it, compare it, print it smaller. But CSS also defines a computation, the cascade: given a stylesheet and a tree of elements, decide which declarations win for each element after selector matching, specificity and source order. A browser runs it against the DOM, but nothing in the algorithm needs a browser. The library already has a typed selector AST and a specificity function, so the same code can run the cascade against any tree.
cascade apply does that for an HTML page. It resolves the page's
<style> blocks, plus an optional extra stylesheet, against every
element and writes each element's winning declarations into its
style attribute, the way email-inlining tools such as
juice prepare HTML for
clients that ignore <style>.
From this stylesheet:
.card > h2 { color: navy }
#lead { color: crimson; font-weight: bold }
ul li + li { margin-top: 4px }
it resolves the page to:
<div class="card">
<h2 style="color:crimson;font-weight:700" id="lead">Title</h2>
<ul><li>one</li><li style="margin-top:4px">two</li></ul>
</div>
The heading is crimson, not navy: #lead beats .card > h2 on
specificity for color, while its font-weight applies unopposed.
Only the second list item gets a margin, because li + li matches an
item that follows another. And the attributes are written in the
printer's minified spelling, so font-weight: bold becomes 700.
Not everything has an inline form. :hover, media queries,
pseudo-elements and @keyframes describe state or boxes that a
style attribute cannot express, so their rules stay behind in a
single <style> block and the command reports how many it kept.
var() references are kept too, with each element's custom
properties resolved onto it, because substituting a reference with
its current value would break any script that retunes the property
later; that substitution exists, but as the explicit opt-in of the
next section. A --minimal flag drops an inherited declaration
whenever the element would inherit the same value anyway, for the
smallest styled page.
Whether the inlining preserves the render is checked in a real
headless browser: cascade inlines a set of committed pages, in both the
full and --minimal modes, and the complete getComputedStyle of
every element has to match the original page, down to the canonical
comparator that treats render-equivalent respellings (red against
rgb(255, 0, 0)) as equal. Computed style is not full rendering, and
the check runs on demand rather than in CI.
apply is also the least HTML-specific part of the tool. The library
side is a functor over a small node interface, names, classes,
attributes, parents, children; the CLI's HTML tree is one
implementation of it. Anything tree-shaped can run the cascade, with
no browser in the loop.
Inlining imports and variables
apply leaves @import rules and var() references untouched. The
format-and-minify command, cascade fmt, can resolve them away
instead, through two opt-in flags. --inline-imports follows every
@import on disk, splicing
the referenced files, and their own imports in turn, into one
stylesheet; cache-busting query strings and fragments are stripped
before the file is opened, and remote URLs are left as they are.
--inline-vars substitutes each var(--name) reference with the
value of its --name definition, then drops the definitions once
nothing reads them:
/* theme.css */
:root { --brand: #0b6efd; --pad: 12px }
/* main.css */
@import url("theme.css?v=2");
.btn { color: var(--brand); padding: var(--pad) }
$ cascade fmt --inline-imports --inline-vars main.css
.btn {
color: #0b6efd;
padding: 12px;
}
Neither flag is on by default. Inlining an import assumes the file on disk is the one the browser would have fetched; inlining a variable assumes no script rewrites it at runtime. Those are claims about your build and your page, not about the stylesheet, so both are opt-ins in the same spirit as the safety flags.
When a custom property is redefined in a different scope, its value depends on which elements match, so there is no one value to substitute in. Cascade keeps the variable live even with the flag on:
/* dark.css */
:root { --brand: #0b6efd }
.dark { --brand: #8ab4f8 }
.btn { color: var(--brand) }
$ cascade fmt --inline-vars dark.css
Warning: --brand is redefined in a different scope; kept live (cannot inline safely)
The output keeps both definitions and the var() reference
untouched. A --keep-vars list marks the properties a script does
own, a theme toggled from JavaScript being the usual case, and those
stay live through the substitution too.
The rule merger
The minifier's global move, merging rules across the whole file, is not new: it has been described in the SatCSS paper, CSS Minification via Constraint Solving (Hague et al., TOPLAS 2019). The paper's observation is that rule order only matters pairwise: two rules must keep their relative order only when some element could match both at equal specificity and they write the same property at equal importance. Every other pair is free to move. Cascade records the constrained pairs as edges in a graph and runs the greedy loop the paper describes over it: commit the merge that saves the most bytes first, wherever the two rules sit in the file, and reject any that would violate an edge. Reading the rules back off the graph with a content-derived key instead of their source order gives the canonical form the diff subcommand compares.
The paper also gives a Max-SAT formulation for the provably smallest stylesheet, but that is the wrong thing to run on every build. Cascade keeps that solver as an oracle in its test suite instead, an optimum the greedy loop is measured against without ever running a solver itself.
Size and speed
Cascade minifies towards a size objective you choose. The default,
--objective=transfer, minimises the gzipped size: the optimiser
carries its own DEFLATE model, a greedy LZ77 parse over a 32 KiB
window costed with RFC 1951's tables, and keeps a rewrite only when
the estimated transfer size shrinks, since repeated declaration text
is nearly free once compressed. --objective=raw chases raw bytes
instead, the right target when CSS ships uncompressed, as inline
style attributes or email HTML do; under it cascade emits the
smallest raw output on all six fixtures. The other tools each
optimise one fixed way.
The tables and chart below put that output next to csso, lightningcss,
esbuild, and cssnano on six SatCSS fixtures. They come from one script,
bench/blog_tables.sh
in the cascade tree, run with the tool versions above on an Apple M5
Max. Timings come from hyperfine, ten runs after two warmups; each
measurement starts a fresh process, so the JavaScript numbers include
Node start-up. The default output has a few bytes of run-to-run wobble
from parallel merge ordering, so a rerun can move exact cells slightly.
On the wire, with the default objective, cascade emits the smallest gzip on four of the six fixtures. lightningcss, given its target list, is smaller on guardian and youtube. The comparison flatters cascade: it optimises for gzipped size and is scored on gzipped size, while the other four optimise raw bytes and are marked on a metric they never targeted.
| Site | input | cascade | csso | lightningcss | esbuild | cssnano |
|---|---|---|---|---|---|---|
| github | 34,961 | 34,249 | 34,874 | 34,428 | 34,737 | 34,859 |
| guardian | 27,922 | 25,854 | 26,636 | 25,545 | 27,599 | 26,938 |
| youtube | 34,929 | 33,568 | 34,827 | 33,539 | 34,815 | 34,783 |
| netflix | 39,965 | 29,440 | 33,089 | 29,767 | 33,799 | 32,749 |
| amazon | 18,121 | 17,264 | 18,074 | 17,440 | 18,032 | 18,019 |
| cnn | 4,672 | 4,222 | 4,474 | 4,509 | 4,574 | 4,511 |
Sizes are gzip bytes; lower is better. These six are real production
stylesheets, and not cherry-picked: across the full benchmark corpus
of seventy-two fixtures, lightningcss rejects one, and of the rest
cascade emits the smaller gzip on fifty-nine and lightningcss on
twelve. The merger only pays off where
rules overlap. On a low-overlap file there is little to merge, and the
byte count turns instead on value-level rewriting, folding a colour or
a calc. Cascade does some of that, but lightningcss ships a much deeper
catalogue of per-property value minifications built up over years, and
on these files that catalogue, not the merger, is what wins it most of
its twelve. I would be looking at closing this gap for 1.1.0.
The safe invocation is nearly free: --lossless and --enforce-spec
together add at most a few hundred gzip bytes on five of the six
fixtures. Only netflix pays more, around 2,000, because it is dense
with vendor prefixes that --enforce-spec keeps and the default
strips.
The merger costs time. It runs for a tenth of a second on the smallest fixture and six-tenths on netflix, where the Rust and Go tools take milliseconds. Minification happens once at build time, so that rarely matters; it would only bite in a fast edit-and-rebuild loop or a batch of thousands of files. It is native code, so the cost is the merge work itself, not the language.
Re-minifying production CSS
The fixtures above might have been minified before I got them. So I
took CSS that certainly was: the shipped .min.css of Bootstrap 5.3,
Bulma 1.0, Foundation 6.8, and Font Awesome 6.5, the live
stylesheets of MDN and GOV.UK fetched in mid-July 2026, and an unpurged
Tailwind v2 build as a stress case. I ran all of it through
cascade fmt --minify --lossless --enforce-spec, the safe invocation,
so what cascade removes is duplication the upstream minifier left
behind rather than a rewrite it declined to make.
| already-minified input | raw saved | gzip saved | brotli saved |
|---|---|---|---|
| Foundation 6.8 | 11.5% | 5.1% | 3.6% |
| GOV.UK (live) | 6.3% | 3.6% | 3.4% |
| Font Awesome 6.5 | 5.6% | -0.8% | 1.1% |
| Tailwind v2 (unpurged) | 5.4% | 4.4% | -20.6% |
| Bootstrap 5.3 | 3.0% | 0.0% | -0.9% |
| Bulma 1.0 | 2.2% | 0.4% | -7.7% |
| MDN (live) | 1.7% | -0.7% | -1.9% |
There is no whitespace left in these files, so what cascade cuts is structure: duplicated rules and foldable declarations. Foundation sheds a tenth of its raw bytes; most of the rest barely move once compressed, and several grow (the negative cells).
Those are all safe-invocation numbers. Drop --enforce-spec and MDN's
raw saving jumps from the 1.7% in the table to about a third of the
file, almost all of it cascade unwrapping the @supports guards it
judges the modern web no longer needs. lightningcss keeps them,
correctly, because removing them assumes a browser, and --enforce-spec
refuses it too.
The negative brotli cells mark the limit of the approach. gzip cannot see repetition more than 32 KiB apart, and cascade's transfer model inherits that horizon; brotli's window spans megabytes, and it was already compressing the distant near-identical rules of an unpurged Tailwind build for almost nothing, so merging them makes the compressed file larger. Past a certain volume of framework CSS served with brotli, structural minification stops paying, and the purge step Tailwind ships is the right tool there. On every SatCSS fixture, cascade's output compresses smaller than its input under both compressors.
The test suite
The evidence for the contract comes in layers, most of them borrowed from other people.
Fuzz suite. Cascade ships generated property tests, run with
Alcobar, a property-based
testing library derived from Crowbar. The properties include
minify(minify(x)) = minify(x) (idempotence on the printed form),
optimize(optimize(x)) = optimize(x) (idempotence on the AST), and
roundtrip stability. One category synthesises small clusters of
sibling rules with overlapping declarations, where one merge exposes
another; that class of bug used to surface only at scale on
real-world stylesheets, before the fuzzer learned to generate the
shape on purpose.
SatCSS oracle. Cascade cross-checks a corpus of real-world CSS
fixtures (twitter, wordpress, msn, arxiv, among others) against
matthewhague/sat-css-tool,
the paper's own Max-SAT minifier. The solver computes the smallest
stylesheet reachable by legal rule merging alone, under the
captured-page assumption the paper uses, so the check runs cascade
under the same assumption and demands that its output is no larger
than the solver's. Two fixtures stay larger, vk-3 by 17 bytes and
google-2 by 39, and the test pins them as known-larger so a fix that
closes the gap gets noticed.
External oracles. Cascade parses every vector in the vendored
css-syntax
suite from web-platform-tests/wpt, with no exceptions: a failing
vector is a bug to fix. keithamus/css-minify-tests
sets an agreed expected output that cascade must match, unless a
reviewed override records both answers and the reason for the
disagreement. And a differential suite runs the other minifier CLIs
on every minify test case from parcel-bundler/lightningcss and
caches their answers; cascade passes when its output is no longer
than the shortest answer its own parser accepts, which makes this a
size oracle rather than an independent semantic one.
Render differential. Everything above checks bytes and parsing,
not rendering. The same headless-browser harness that verifies apply
also minifies each <style> block of a set of committed pages in place
and fails when any element's computed style changes, whatever the byte
count says.
Getting your feedback
Underneath all these tools, cascade is a library: CSS as a typed value
in OCaml that you can parse, compare, rewrite and print without ever
touching a string. The minifier is one consumer of that library. The structural diff is another,
and the part I use most: I could not find another CSS diff that
ignores cascade-safe reordering. apply and the inline flags resolve a
stylesheet against a page or flatten its @imports and variables. The
Tailwind port is the reason any of this
exists.
If you want to help, run it over your own CSS:
$ cascade fmt --minify --lossless --enforce-spec your.css > out.css
$ cascade diff --diff=canonical --lossless your.css out.css # exits 0
If it breaks something under those two flags, that is a bug you can report on the issue tracker or by email.
