Thomas Gazagnaire

Thomas Gazagnaire

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

A CSS Engine in OCaml

2026-04-02

I have rewritten my website in OCaml more times than I can count (from ocaml-cow to Canopy to custom MirageOS unikernels). This time, the styling was the problem: I use Tailwind CSS, and I wanted the entire pipeline (Markdown to styled HTML to CSS) to be a single dune build with no Node.js dependency.

That meant porting Tailwind's CSS generation to OCaml. To make sure the port was correct, I needed to compare its CSS output against the JavaScript original. The existing CSS diff tools are either abandoned or limited to CSS 2/3, and none of them handle @layer, container queries, nesting, or the modern colour spaces that Tailwind v4 generates. So I started writing one. Which needed a complete parser. Which grew into a typed AST and an optimiser, and ended up being useful on its own.

The result is Cascade, a 30,000-line OCaml library for parsing, generating, optimising, and diffing CSS. One of the tools it ships is cssdiff, a structural CSS comparison tool. Since Cascade is pure OCaml, it can be compiled to JavaScript via js_of_ocaml and run in the browser. Try it, or pick one of the examples below:

Examples:

The toolkit

Cascade parses CSS strings into a typed AST, and provides a small eDSL to build the same AST from OCaml code. From that AST, it can render (pretty-print or minify), optimise (deduplicate and merge rules), and structurally diff two stylesheets. The parser uses hand-written recursive descent (does Claude have hands?) covering CSS Syntax Level 3 through Level 4 and 5 (modern selectors, colour spaces, @layer, container queries, nesting).

let css = Cascade.Css.of_string {|
  .btn {
    display: inline-block;
    background-color: #3b82f6;
    color: white;
    padding: 0.5rem;
  }
  .btn {
    background-color: #2563eb;
  }
|}

Parse. The input has a duplicate .btn rule (common in generated CSS). The parser preserves both and returns a typed AST.

open Cascade.Css

let btn = Selector.class_ "btn"

let rules =
  [ rule ~selector:btn
      [ display Inline_block
      ; background_color (hex "#3b82f6")
      ; color (hex "#ffffff")
      ; padding (Rem 0.5) ]
  ; rule ~selector:btn
      [ background_color (hex "#2563eb") ] ]

Generate. The same two rules, built from OCaml values. A typo like dsiplay is a compile error. Passing a colour to padding is a type error.

.btn {
  display: inline-block;
  background-color: #3b82f6;
  color: #fff;
  padding: 0.5rem;
}
.btn {
  background-color: #2563eb;
}

Print. Css.to_string (Css.v rules) renders the AST back to CSS. Both rules are preserved. The printer shortens white to #fff.

.btn {
  display: inline-block;
  background-color: #2563eb;
  color: #fff;
  padding: 0.5rem;
}

Optimise. Css.to_string ~optimize:true (Css.v rules) merges the two .btn rules, keeping the last background-color (cascade order). It respects !important and preserves intentional patterns like content fallbacks.

The .btn example uses a few properties, but the library has 100+ typed constructors covering layout (box model, flexbox, grid), visual (typography, transforms, animations, filters), and logical properties.

Testing against Tailwind

How do you know a CSS parser is correct? The usual answer is "read the spec", and the W3C test suite covers parsing for individual features. But those tests check that browsers apply CSS correctly, not that a tool can safely transform it. How do you know that merging two rules across cascade layers or deduplicating a property does not change what the page looks like?

One approach that I found works well in practice is differential comparison against a reference implementation. For instance, to port Tailwind to OCaml, I run the JavaScript Tailwind CLI on the same input, then run the OCaml port, and compare the two CSS outputs byte for byte. The comparison is strict: same characters, same order, same whitespace. Not "structurally equivalent" (that would hide bugs in the comparison tool itself).

When the outputs diverge (and they do, constantly, during development), I need to know which rule changed and how. A 50,000-line string diff is unreadable. That is what cssdiff is for: a structural diff that says "rule .mt-4 changed margin-top from 1rem to 16px" is actionable. It works at the AST level, so a rule that moved from line 10 to line 200 shows as reordered, not as a deletion plus an addition. It handles @media, @layer, @supports, and @container blocks. This is the same tool running in the demo above.

The byte-for-byte target also keeps the optimiser honest. It would be easy to produce "correct" CSS that differs in harmless ways (different property order, different hex capitalisation). But allowing those differences means the diff tool would need to know which differences are harmless, which is exactly the kind of subtle bug that hides semantic errors. Matching the reference output exactly eliminates that class of problems.

Getting started

Two CLI tools ship with the library: cascade (format, minify, optimise CSS files) and cssdiff (structural comparison).

brew install samoht/tap/cascade

Or via opam:

opam pin add cascade https://github.com/samoht/cascade.git

The library requires OCaml 4.14 or later. The README has the full API overview and CSS specification coverage table.

I wrote Cascade because I needed it for my blog. It turned out to be useful beyond that: any OCaml project that generates, parses, or compares CSS now has a typed alternative to string manipulation. The source is on GitHub.

References

Related Posts