I really like Tailwind CSS. Keeping styles next to the HTML makes it much easier to keep the two in sync. I can see which styles a component uses without tracing selectors across files, and removing the component doesn't leave me wondering which CSS rules are still needed elsewhere.
In my projects, though, Tailwind also brought a Node.js toolchain into
the build. I use OCaml for most of my software projects, including the
code that generates HTML for web applications (like this blog!), so
keeping a second build loop in sync was awkward. I wanted the same convenience within
OCaml: a component could carry its Tailwind styles with it, and the
build could generate both HTML and CSS together. That library became
tw, which, as of its 1.1.0 release,
builds whole Tailwind v4 projects, with their themes and plugins, and
without Node.js. It is a drop-in replacement for the Tailwind CLI,
whatever language your project is written in:
$ brew install samoht/tap/tw # or: opam install tw
$ tw -i src/app.css -o dist/app.css
But how could I tell whether it was a faithful replacement? Different CSS can produce the same page, and a stylesheet that looks plausible can still move a button or break a hover effect. This post describes the checks I built to answer that question. Each of them ended up trusting something it should not, and in the end I had to let the browser decide.
Comparing the CSS
Tailwind itself gave me the first oracle: an implementation that
supplies the expected output. I fed the same classes to Tailwind and
tw, compiled both, and compared the resulting CSS. Tailwind's own
utility and variant fixtures provided the first inputs, followed by
whole-project stylesheets that made the features interact. Each
disagreement gave me something small to investigate.
At first I aimed for identical bytes. That caught details a visual inspection would miss: a selector escaped incorrectly, a missing variable, a slightly different fractional width. It also turned a harmless change in whitespace or colour spelling into a failure. Once I started optimising the output, two compilers producing the same file was no longer the result I wanted. I wanted to allow different CSS that did the same job.
So I needed a CSS-aware comparison. That became
cascade, which I
wrote about in July: it parses both
files, normalises equivalent spellings, and reports the selectors and
declarations that differ. With differences reported by rule, porting
became much more pleasant: a change in padding no longer meant reading
a line of several thousand characters.
Who checks the checker?
There is a problem with writing both the compiler and its checker.
tw and cascade share CSS machinery: tw prints its output through
cascade, and cascade also minifies it. If both mishandle the same
construct, their agreement hides the mistake.
For example, here is one HTML file and two possible stylesheets. Are they equivalent? Careful: your answer could have a big impact on your next software project!
<style>
.advice { position: relative; }
.advice > .ocaml { position: absolute; inset: 0; background: white; }
</style>
<link rel="stylesheet" href="a.css">
<p class="advice">
<span>You should use Rust</span>
<span class="ocaml">You should use OCaml</span>
</p>
One HTML page. Change the stylesheet link to b.css to compare.
/* a.css */
.ocaml { all: unset; }
.ocaml { visibility: hidden; }
/* b.css: just swap the two rules. */
.ocaml { visibility: hidden; }
.ocaml { all: unset; }
Two candidate stylesheets. The reset and visibility rules trade places.
The two rules don't name any of the same properties, so a tool that
looks at each property separately could swap them. But
all: unset also resets visibility, to its inherited value,
visible here. With a.css the span is hidden and the reader sees
Rust. With b.css it is visible and covers Rust. The positioning rule
has higher specificity, so the reset doesn't move the span; it only
changes whether you can see it.
a.css
You should use Rust
b.css
You should use OCaml
cascade reports the change to visibility:
$ cascade diff --diff=canonical a.css b.css
CSS: 54 chars vs 54 chars (0.0% diff)
Changes: 1 modified rule
--- a.css
+++ b.css
└─ .ocaml
- visibility: hidden
Catching this kind of mistake mattered more than usual, because since
last year
a mix of LLMs, some in the cloud and some running locally, has
written much of the code in both tools, while I reviewed the changes and
decided what to build next. That was partly an experiment in how to
drive these models towards software I would trust, and it only works
if the tests decide what gets accepted. But a model can change a test
and the code it checks at the same time, and I cannot use cascade to
check that cascade is correct. I needed a check that shares no code
with either tool, and that neither I nor the models could change.
Asking the browser
I turned to headless Chrome. My first harness loaded a page under each
stylesheet, read every element's computed style through
getComputedStyle, and compared the values.
This had two problems. First, computed values can be written in many
equivalent ways, so the harness used cascade's own value comparator
to decide which differences were real. The checker depended on the
code it was supposed to check. Second, computed styles are not what
users see. cascade minifies background:none to background:0 0,
one byte shorter and painting exactly the same, yet getComputedStyle
reports
background-position as 0% 0% for one and 0px 0px for the other.
So the harness now compares pixels. It renders the page under each
stylesheet, at every viewport width the stylesheets' media queries
mention and in every interaction state they use (:hover, :focus,
and so on), and compares the screenshots. It reads computed styles
only where pixels differ, to find which property is responsible. The
same check is available from the command line:
$ cascade diff --browser --html page.html a.css b.css
Browser: 153.0; viewports: 1024x768; states: none
Renders that differ: 1
1024x768 none: 149x13 pixels differ at (142,18)
Computed values the elements under those pixels disagree on:
body>p.advice:nth-child(1)>span.ocaml:nth-child(2)
visibility: hidden -> visible
A test only covers the documents and states it renders, so the example needs both spans, just as a hover rule needs a hover test. Some properties paint nothing at all: a cursor, or the timing of a transition. For those, the CSS comparison is still the only check, and that is why I keep both.
Testing the diff itself
With an independent reference, I could then test cascade's
comparison directly, using mutation testing. The harness takes real
stylesheets and breaks them mechanically: it drops a declaration,
drops a rule, swaps two neighbouring declarations or rules, or splits a rule in two.
Some of these mutants change the page, like our reset and visibility
swap. Others are harmless, like removing a declaration that a later
one overrides.
Every time cascade says a mutant is equivalent to the original,
Chrome renders both. If the pixels differ, cascade has missed a
change, and that is a bug. cascade's verdict is only used to choose
which pairs to render, so it can make the test slower but never make
it pass. Several fixes in cascade 1.2.0 are cases where Chrome
disagreed with its output. Many others come from a single pattern:
parts of the minifier walked the stylesheet with their own match and a
catch-all case, so an unfamiliar statement was silently skipped. If
you used 1.1.0 to minify a page, regenerate it with 1.2.1 and diff the
two.
The whole of tailwindcss.com
The largest test is the Tailwind website itself, which uses far more
class combinations than any fixture. Every class it uses is compiled
by both tools and rendered on its own element, inside wrappers that
make the group-* and peer-* variants match. On a full run with
tw 1.1.0 and cascade 1.2.1, cascade found no difference between
the two stylesheets, and Chrome agreed at every viewport width and in
every interaction state. tw's minified output was also slightly
smaller than Tailwind's.
That run still has limits. A variant that tests an ancestor's
attribute, such as group-data-[checked]:, matches on neither side,
so it is compared but not really exercised. And a few classes on
the site are documentation placeholders such as blur-[<value>], for
which Tailwind emits CSS that no browser accepts and tw emits
nothing. Both pages render the same, so I count that as parity.
Using it on your project
tw reads the same CSS entrypoint as the tailwindcss CLI, with
@theme, @source, custom utilities and variants, and the typography
and forms plugins. It does not run JavaScript, so a project still using
tailwind.config.js needs to move that configuration into its CSS
entrypoint first.
To check tw against Tailwind on your own project, add --diff. It
compiles the project with both tools and explains the differences with
cascade; with --html, it also compares the rendering of one of your
pages in headless Chrome. This needs Tailwind 4.3.3 installed locally,
but the normal build does not need Node.js at all.
$ tw -i src/app.css --diff --html public/index.html
cascade works on CSS from any other tool too, for instance to check
that a minifier did not change your page:
$ brew install samoht/tap/cascade # or: opam install cascade
$ cascade diff --diff=canonical input.css output.css
$ cascade diff --browser --html page.html input.css output.css
In OCaml, I skip the scanning step altogether, as each component carries its own styles:
open Tw_html
let card ~title ~body =
article ~tw:Tw.[ flex; flex_col; gap 4; p 6; rounded_lg ]
[ h2 ~tw:Tw.[ text_xl; font_semibold ] [ txt title ];
p [ txt body ] ]
Reusing card brings its CSS along, without a source scanner or a
safelist. The April post shows the
full workflow.
Getting your feedback
There are probably still bugs that none of these tests reach. If
tw --diff reports a difference on your project, it is a bug in
either the compiler or the comparison, and I would like to hear about
it: a small reproducer on the tw issue
tracker or by
email is perfect. Reviews of either
codebase are very welcome too. Try it, and tell me what breaks.
Tailwind Labs' implementation, documentation and tests have been
essential to this work, and tw depends on the framework they
continue to develop. If you use Tailwind, through either compiler,
please support the team by
sponsoring them or buying
a Tailwind Plus licence.
