← Back to blog
Product July 11, 2026 by Javier Arancibia

Tailwind Without Node: 172 kB, 16 ms, and a Way to Prove It

Tailwind without Node: 172 kB, 16 ms, and a way to prove it

I needed Tailwind for a language with no npm ecosystem. So I rebuilt the engine in that language — and let the real tailwindcss binary grade every rule it emits.


machin is my machine-first language: terse, type-inferred MFL that compiles through C to one static binary. It does both ends of the web in one language — a native HTTP server and a reactive WebAssembly client — with no Node, no bundler, no node_modules. Which left exactly one awkward guest at the party: the stylesheet.

I wanted Tailwind. Not something Tailwind-ish — actual Tailwind class names with actual Tailwind semantics. The reason is the whole thesis of the language: the developers are agents now, and agents arrive already fluent in Tailwind. Every model has read millions of flex items-center gap-2. That pretrained fluency is free leverage — but only if compatibility is real. A dialect that is 90% Tailwind is worse than no Tailwind, because the agent cannot tell which 10% will silently do nothing.

Tailwind’s JIT engine runs on Node. Shipping a Node toolchain next to a language whose entire pitch is “one static binary” was not going to happen. So I wrote the engine in pure MFL.

Don’t claim compatibility. Measure it.

The interesting part isn’t the rewrite — it’s how you make “100% compatible” a checkable statement instead of a marketing line.

The repo keeps the real tailwindcss standalone binary as a dev-only oracle — it is never a dependency of anything you ship. A harness generates a corpus of 3,692 classes across every implemented family and variant, runs both engines, normalizes both stylesheets into (media query, selector, declarations) sets, and diffs them. Not “looks right” — set-identical:

corpus (3692 classes): oracle rules: 3702 | ours: 3702
corpus darkMode:class:  oracle rules: 3702 | ours: 3702
scanner (shared fixture): 66 | 66
cascade order: 15 conflict pairs match the oracle
preflight: byte-identical with @tailwind base
PASS

It goes further than emission. The scanner is differential-tested too: both engines scan the same MFL source file and must produce the same stylesheet. The preflight isn’t hand-copied — it’s captured from the oracle’s own @tailwind base output and asserted byte-identical. And every finite utility family — the 244-color palette, 600 static classes, the keyframes — is generated from the oracle’s output, so the tables are ground truth by construction. What the oracle doesn’t resolve, my engine doesn’t either. Hand-written code only covers the genuinely parametric surface: spacing math, negatives, color templates, opacity modifiers, arbitrary values, variant composition.

What the oracle caught

This is the part that convinced me the approach pays for itself. I would never have gotten these right from documentation:

  • 2xl:flex — CSS selectors can’t start with a digit, so Tailwind hex-escapes it: .\32xl\:flex.
  • :visited strips the opacity variable from colors — browsers forbid alpha styling on visited links.
  • via-blue-500 emits a double space in one declaration. Faithfully reproduced.
  • md:animate-spin nests the @keyframes block inside the media query.
  • placeholder: emits twin rules — ::-moz-placeholder and ::placeholder. My components had used the variant for weeks while the engine silently dropped it; the dark-mode pass exposed it.
  • And the big one: cascade order. Set-comparison can’t see rule order, but the browser can. Alphabetical emission let border-stone-200 override border-l-green-600, and put dark:text-stone-900 before text-white — white-on-white buttons in dark mode. Rules now emit like Tailwind’s layers: shorthand → axis → side, base utilities → variant depth, with an order-sensitive suite pinning 15 conflict pairs to the oracle.

Each of these started as a red diff, ended as a permanent regression test, and cost minutes to fix because the oracle prints the expected output. That loop — probe ground truth, generate from it, diff against it — is the same discipline I used for inferred data-race freedom: don’t argue the property, demonstrate it.

The numbers

                 machin-web-ui     tailwindcss standalone
corpus, 3,692 classes   16 ms         1,342 ms
binary size            172 kB            43 MB
runtime deps             none      none (bundled Node)

84× faster and 250× smaller is not the point — it falls out of writing a scanner and a lookup table in a compiled language. The point is that the whole styling toolchain now lives inside the same binary that scaffolds the app.

Then I built shadcn on top of it

The engine was milestone zero. The actual product is machin-web-ui: an agent-first design system for isomorphic machin apps, and the shadcn model turns out to be accidentally perfect for agents — components are vendored source you own, not an opaque dependency. The agent that installs a component can read it, and edit it.

machin-web-ui init my-app      # SSR server + wasm client, builds out of the box
machin-web-ui add dialog table # copy component source into your repo
machin-web-ui list             # 60 components, JSON
machin-web-ui coverage         # exactly which Tailwind surface exists (and doesn't)
machin-web-ui check md:hover:bg-stone-700   # does this class resolve? to what?

Sixty components now: the usual suspects (buttons, forms, tables, dialogs, drawers, tabs), charts as pure-MFL inline SVG (data-driven geometry lives in SVG attributes — which neatly sidesteps the classic dynamic-class trap), a custom range calendar whose date math is pure MFL (Zeller’s congruence, so server and wasm can never disagree about a weekday), and an agent-era set nobody else leads with: chat transcripts, log streams, diff viewers, a ctrl-K command palette. Form controls are no-JS where possible — checkboxes, radios and switches are sr-only native inputs driving styled siblings via peer-checked:.

Every component renders on both sides of the wire: the server calls it for SSR first paint, the wasm client calls the same function inside hydrate() and attaches reactive bindings to the server’s DOM. One language, one view function, no flash. Dark mode ships both ways — the OS-following media strategy by default, or --dark-class for a live toggle (dark: variants become :is(.dark *) selectors, oracle-verified against darkMode: 'class' like everything else).

The proof is the gallery: javimosch.github.io/machin-web-ui is the design system dogfooding itself — statically exported (the SSR page printed by the binary’s own --render flag, the wasm client hydrating it on GitHub Pages, no server anywhere), and driven by a 40-assertion headless-Chrome suite that runs against localhost and production alike. Press ctrl-K when you get there.

The binary is the docs

There is no documentation site, and that’s deliberate. In 2026 the consumer of a framework is an agent operating a terminal: it asks coverage what exists, check whether a class resolves before shipping it, list what it can vendor. Unknown classes in an explicit list come back loud, in a header comment, never silently dropped. The honest gaps — container queries, filters, arbitrary variants, theme customization — are enumerated in the same JSON. An agent can know, before writing a single class, exactly where the floor is.

That’s the pattern I keep landing on with machin: pick a property everyone hand-waves (“it’s Tailwind-compatible”, “it’s data-race free”), make it mechanically checkable, and let the checker do the arguing. The stylesheet engine took three days from empty repo to oracle-identical. The design system on top took four more, and now every machin app starts with machin-web-ui init — styled, isomorphic, and dark-mode-ready in one command.

Code: github.com/javimosch/machin-web-ui · live gallery: javimosch.github.io/machin-web-ui · the language: github.com/javimosch/machin.