Thomas Gazagnaire

Thomas Gazagnaire

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

Tailwind Without Node

2026-04-21

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

Every keystroke in the left panel fully regenerates the right iframe.

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/3 should print as 33.3333%, not 33.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: #ffffffcc becomes #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:flex must 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.

References

Related Posts