Tailwind CSS introduced a clever way to manage the HTML/CSS split: encode CSS properties as class names. This solves a recurrent friction in large codebases: keeping HTML and CSS in sync. Tailwind's build scans the source and generates exactly the classes needed to style it. You can (mostly) copy-paste an HTML snippet anywhere and its stylesheet follows. As in other domains, referential transparency is a really nice property, and helps programmers (and designers!) make fewer mistakes, especially when eagerly refactoring a codebase.
However, scanning HTML snippets comes with downsides. When the HTML is generated (either statically by a blog engine, or dynamically with browser apps), the strategy falls short. Tailwind proposes various strategies to overcome this (like embedding static CSS classes in its config file), but I don't find them very helpful when I'm developing in OCaml.
So six months ago I started reimplementing Tailwind in OCaml.
tw began life targeting v3, where
classes project directly to CSS values; it has since fully migrated
to v4, a substantial architectural change that swaps concrete
values for a variable-based theme layer. It passes the 905 upstream
tests extracted from
Tailwind's own TypeScript suites,
and the tw CLI emits CSS that is byte for byte identical to the
reference
tailwindcss under --optimized --minify. Here is tw compiled
to JavaScript with
js_of_ocaml. Paste any HTML
snippet on the left and the CSS regenerates in a sandboxed iframe
on the right:
Generated CSS
Under the hood, Tw.of_string parses each class and Tw.to_css
compiles the set into a v4 stylesheet. Unknown classes show up in
the red panel. The palette, arbitrary values (bg-[#ff00ff],
bg-[oklch(60%_0.2_30)]), and every variant combination (hover:,
dark:md:, group-hover:) work. What doesn't is Tailwind's
config-level machinery (@theme, @apply, custom tokens); those
are exposed on the OCaml side via a Tw.Scheme.t argument to
Tw.to_css.
A Tailwind v4 bonus
Since Tailwind v4, every utility resolves through a CSS custom
property (var(--color-gray-900), var(--spacing), and so on). That
makes the v4 engine substantially more complex than v3, where
utilities were emitted as concrete values, but it also makes the
output tweakable from the browser at runtime: change a token, the
whole page restyles. tw inherits this along with everything else
Tailwind v4 gives for free. Drag a colour or spacing below and look
around the page:
Why an OCaml port
My OCaml web stack had one irreducible dependency on Node. Generating a Tailwind stylesheet meant invoking the npm package or bundling the Rust-compiled standalone binary. Nothing else in an OCaml web project still required a non-OCaml toolchain.
The trigger was mirage-www,
the MirageOS homepage. It is a 1,100-line
OCaml application serving
TLS traffic through an OCaml stack: HTTP, TLS, TCP/IP, and the
ethernet and block-device drivers are all written in the same
language and linked into one self-contained binary. The stylesheet
was the one thing the build could not produce from its own
dependencies. Contributors on FreeBSD and Windows kept hitting
missing binaries and npm drift; Tailwind has improved its platform
coverage since, but for a project whose other inputs are all OCaml,
the gap was awkward. Pull request
mirage-www#860
deletes the tailwindcss script tag and the npm dev-dependency and
replaces them with tw.html directly.
Once the PR lands, the build needs only an OCaml compiler and opam.
What it covers
tw implements every core Tailwind v4 utility and both official
plugins,
@tailwindcss/typography
and
@tailwindcss/forms.
Responsive variants, state variants, dark mode, group and peer
modifiers, pseudo-elements, OKLCH colours, the color/opacity
syntax, arbitrary values like min-h-[6rem], and the preflight reset all
behave the way they do in the reference binary. The tests
themselves come straight from the upstream project: each case
pairs an input class with the expected CSS from Tailwind's own
suites. tw's runner reparses both sides so that whitespace
differences do not matter, and any other difference fails the
test.
The string API sits on top of a typed OCaml AST. Tw.of_string
parses each raw class into that AST, which is then lowered through
several stages into the final stylesheet. Building the AST
directly instead of going through strings catches misspelled
utilities as compile errors, and the combinators reject invalid
combinations:
open Tw
let button =
[ flex; items_center; gap 4; px 6; py 3;
bg blue; hover [ bg ~shade:600 blue ];
rounded_lg; shadow_sm;
dark [ bg ~shade:800 gray; text ~shade:100 gray ] ]
The same binary produces the utility strings for HTML and the CSS
for the stylesheet, from one source, in one dune build.
Shipping HTML and CSS together
OCaml has several mature libraries for composing HTML fragments
type-safely. Tyxml gives you a
W3C-accurate AST; Dream has
Dream.html; Htmlit is a
small combinator library; Eliom
crosses the client/server boundary. None of them answer the CSS
question. Styled fragments either carry inline style="..."
attributes (no caching, no variants, no hover states) or refer to
classes defined in a separate hand-maintained stylesheet.
Classes in tw are first-class OCaml values. A UI fragment carries
the styles it needs as part of its type, and the build collects
every class used across every fragment into one stylesheet:
open Tw_html
let card ~title ~body =
div ~tw:Tw.[ flex; flex_col; p 6; bg white; rounded_lg; shadow_md ]
[ h2 ~tw:Tw.[ text_lg; font_semibold; mb 2 ] [ txt title ];
p ~tw:Tw.[ text_sm; text ~shade:600 gray ] [ txt body ] ]
Reusing card from another page adds the eleven utilities it needs
to the generated CSS, deduplicated with the rest of the site. The
contract between the HTML tree and the stylesheet is two functions:
Tw_html.to_tw : Tw_html.t -> Tw.t list
Tw.to_css : Tw.t list -> Cascade.Css.t
No source scanner, no safelist, no content: [...] configuration.
Reusable components can be packaged as opam libraries and exchanged
between projects without a CSS-sharing protocol.
Matching byte for byte
Tailwind v4's engine is a CSS generator, so verifying the port means comparing two stylesheets structurally, not as byte streams. A string diff over 50,000 lines of minified CSS is unreadable; a byte-for-byte target is only useful if you can see which rule differs and how when it breaks.
Existing CSS diff tools either predate Tailwind v4 or are limited
to CSS Syntax Level 2/3, so they do not handle @layer,
@container, nesting, or modern colour spaces. So, as I have
discussed before, I had to write one:
Cascade, whose cssdiff CLI parses
both stylesheets into a typed AST (CSS Syntax Level 3 through
Selectors Level 5) and reports rule-level differences: this selector's margin-top
changed from 1.25em to 1em; this @layer has a new
declaration; this @container rule moved from position 10 to
position 200. The tw CLI wraps both halves. tw -s <class> asks
the basic question, what CSS is generated for this class, and
tw -s <class> --diff compares that output against the reference
tailwindcss using cssdiff (forcing --optimized --minify on
both sides).
Porting Tailwind became a tight loop around that second one-liner:
run, diff, fix, repeat. The same one-liner now drives a big chunk
of the test suite: differential testing against tailwindcss, one
class at a time.
The kind of bug cssdiff caught is the kind I would not have found
by reading the spec. A few examples from the commit log:
- Width precision.
w-1/3should print as33.3333%, not33.333333%. Tailwind rounds fractional widths to four decimal places. CSS does not require this; Tailwind does it for stable diffs. - Hex shortening. Tailwind compresses 8-digit hex colours to
4-digit form when each pair is repeated:
#ffffffccbecomes#fffc, but only inside opacity-modifier output. - OKLCH out-of-gamut. Some OKLCH colours fall outside the sRGB triangle; Tailwind's fallback renders the gamut-mapped sRGB approximation per CSS Color Level 4. Implementing the mapping algorithm was a small detour through colour science.
- Variant ordering. Doubly-nested
hover:group-[&_p]:hover:flexmust sort after both single-nested forms, otherwise the optimiser cannot merge their selectors. The comparator distinguishes three nesting levels.
None of these are wrong; they are choices the reference binary makes that only a strict diff would surface.
Getting it
opam pin add tw https://github.com/samoht/tw.git
The code is on GitHub under the ISC
licence. An opam release will follow once the API stabilises.
Try it, and tell me what breaks.
The Tailwind Labs team built the
framework this library targets; the reference tailwindcss binary
is the oracle every tw output is checked against. If you ship
anything built with tw,
sponsor them.
