Thomas Gazagnaire

Thomas Gazagnaire

Building Functional Systems from Cloud to Orbit. thomas@gazagnaire.org

Cascade: A Typed CSS Toolkit in OCaml

2026-07-22 · Discuss on OCaml Discuss

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.

Scatter plot for the netflix stylesheet: gzipped output size against mean wall clock for five minifiers. lightningcss and esbuild finish in under 16 ms and emit 29,767 and 33,799 gzip bytes; csso and cssnano take 140 and 332 ms for 33,089 and 32,749; cascade takes 614 ms and emits 29,440 bytes.
The size and speed trade on the netflix fixture. lightningcss and esbuild finish in a few milliseconds; cascade takes six-tenths of a second in its rule merger and lands the smallest gzip, a shade under lightningcss. Whether that trade is worth making depends on where in your pipeline the minifier sits.

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.

References

Related Posts