Skip to content
Grav 2.0 is officially stable. Read the announcement →
plugin 8 mins

Tailwind CSS in Grav, No Node Required

The new Tailwind4 plugin compiles a theme's Tailwind CSS straight from PHP, and the story of the engine fork we had to maintain to make it correct

Typhoon has been a Tailwind CSS theme since 2022, and Helios was built on Tailwind 4 from day one. Both of them are lovely to work on, right up until the moment you want to change a single class in a Twig template on a live server. Then you need Node. You need node_modules, you need npm install, you need to remember which script builds the CSS, and you need all of that on a box whose whole reason for running Grav is that it's just PHP and a folder of files.

That has bugged me for a long time. So we built the Tailwind4 plugin, and it compiles a theme's Tailwind CSS from PHP. No Node, no npm, no build step, no package.json. You click a button in the admin and 88 milliseconds later the CSS is on disk.

What it actually does

The plugin has three ways in, and they all do the same work:

  • A Compile CSS button in the Admin Next toolbar, with a toast reporting the duration, output size, or the error if the build failed.
  • bin/plugin tailwind4 compile [theme] on the CLI, with --watch for polling and recompiling while you edit, and --diff for comparing against the official Node build.
  • Two API endpoints, POST /api/v1/tailwind4/compile and GET /api/v1/tailwind4/status, gated to theme administrators and super admins.

There's also an optional Auto-compile on Save, off by default, that recompiles when you save the active theme's config.

The one thing it never does is compile on a front-end request. The output is a plain static CSS file at the exact path your npm build already wrote to, build/css/site.css, so your templates need zero changes and the PHP build is a drop-in replacement. You can flip back and forth between the two builds all day.

Here's the report page after a Typhoon build on my machine:

Tailwind 4 admin report page showing a successful Typhoon compile

290 files scanned, 289 of them served from cache, 11,604 class candidates, 75.3KB of minified CSS, 88ms end to end with 54ms of that inside the engine, peaking at 44MB of memory. That's fast enough that clicking the button feels instant, which was the whole point.

Under the covers

There are three moving parts.

The theme contract

A theme opts in by declaring a tailwind4: block in its own yaml, and that's the entire integration. No plugin code, no per-theme registration:

YAML
tailwind4:
  input: css/site.css              # your existing Tailwind entry point
  output: build/css/site.css       # the same path the npm build writes
  sources:
    - self://                      # the whole theme dir
    - user://pages                 # page content and frontmatter
    - user://config                # site and plugin config
    - plugin-templates             # every enabled plugin's templates/ dir
  safelist_files:
    - available-classes.md

Every key is optional. If your theme follows the standard layout, an empty block works, and so does no block at all. Helios declares the whole thing explicitly anyway so the contract is self-documenting and its source list mirrors exactly what the npm build scans.

The scanner

Tailwind only emits CSS for class names it can actually find in your source. The official CLI does that with a fast Rust tokenizer called Oxide, so we wrote the same idea in PHP.

The scanner deliberately over-extracts. It pulls out every run of characters that could plausibly be a Tailwind candidate, including classes buried inside Twig expressions like class="{{ ['flex','gap-2']|join(' ') }}", Markdown attribute lists like {.text-center}, YAML values, and {% set %} assignments. Anything that isn't a real utility gets silently thrown away by the compiler, so over-extracting costs a few milliseconds and never produces wrong output. Under-extracting, missing a class you really used, is the only bug that matters, so the scanner errs heavily toward finding too much.

Each file's tokens get cached as JSON under cache://tailwind4/scan/, keyed on the file's path, mtime and size. That's the 289 cache hits out of 290 files in the screenshot, and it's why a warm compile is so much quicker than a cold one.

The engine

The actual Tailwind compilation runs on TailwindPHP, a zero-dependency PHP port of the Tailwind 4 engine originally written by inline0. We consume it from our own fork, pinned to a tested release and shipped vendored inside the plugin, so a Tailwind upgrade is a deliberate, tested plugin release rather than something that happens to you on a composer update.

Builds write atomically (a failed compile leaves your previous CSS byte-identical), concurrent builds serialize on a per-theme flock so a double-clicked button can't interleave writes, and every build persists a manifest to user-data://tailwind4/<theme>.json with the timings, counts, output size, input hash and engine version. That manifest is what the report page above is showing you.

Why we forked TailwindPHP

TailwindPHP is a genuinely impressive piece of work. Porting the Tailwind engine to PHP is not a simple task! But porting something that big means the edge cases show up later, in real themes, and Typhoon and Helios turned out to be very good at finding them.

The first one was ugly. A rule whose @apply expanded to more than one declaration silently dropped its nested child rules. In Typhoon that quietly broke breadcrumb styling, form labels and nav indentation, and there was nothing in the output telling you it had happened. The second was simpler: the container utility was missing entirely, so we ported it 1:1 from Tailwind's own utilities.ts. Both of those went upstream as PRs (#4 and #5) and both are still sitting there unreviewed.

Then they kept coming.

The minifier was collapsing the descendant combinator in front of a pseudo-class, so .prose :where(h1) got rewritten to .prose:where(h1), which matches absolutely nothing. That is exactly what @tailwindcss/typography emits for every single prose child, so every heading, paragraph, list and code block in your content rendered completely unstyled. That one shipped as plugin 1.0.3.

After that came a batch of smaller ones the fork now carries: CLI source resolution (relative @import siblings, @source globs, negated patterns, scanning yaml/yml/md), theme() resolution inside arbitrary values so [color:theme(--color-primary)] works, --spacing(0) and --spacing(1) shortcuts, a color-mix polyfill for currentcolor, mask angle normalization, and atomic engine-cache writes.

Not all of them were the engine's fault, either. Plugin 1.0.4 fixed a bug in our scanner where a shortcode containing a colon, something as innocent as [figure caption="Source: Archives"], poisoned a page's candidate extraction and knocked out dark mode and responsive styles across the site. Good luck guessing that one from the symptoms.

And one more, today

I found another one this afternoon, which is a fair illustration of how this goes.

Group several selectors in one rule and @apply a variant to them:

CSS
.form-input, .form-textarea, .form-select {
  @apply dark:bg-gray-800;
}

When the nesting was flattened, the grouped selector list was substituted in raw. So .a, .b { @apply hover:underline } came out as .a, .b:hover, where the variant binds to the last selector only and every earlier one in the group gets the declarations unconditionally. Swap hover: for dark: and you get the rule above leaking its dark-mode background into light mode on the first two of every three form elements. The fix is to wrap a grouped parent selector in :is() when it's substituted, leaving single-selector output exactly as it was. That's engine 1.6.2 and plugin 1.0.6, out today. The same bug is present upstream.

The fork isn't a fun side quest, it's the cost of doing this in PHP, and I expect to keep paying it for a while. We offer the fixes upstream, we carry the ones that don't land, and every one of them so far was found by a real theme rendering wrong rather than by a test suite.

Does it match the Node build?

That's the question that actually decides whether you can trust any of this, so there's a tool for it:

BASH
bin/plugin tailwind4 compile typhoon --diff

With the theme's node_modules present, that compiles with the plugin, compiles with the official Node CLI, and diffs the two selector sets. Missing selectors (in the Node build, absent from ours) are bugs and should be empty. Extra selectors are harmless, since the scanner over-extracts by design and occasionally accepts a token Oxide would reject, like a stray arbitrary-property lookalike in a PHP docblock. The command exits 0 when nothing is missing and 1 when something is.

On Typhoon today, zero missing selectors.

Two things to know

GPM theme updates blow away your compiled CSS. The default output path lives inside the theme directory, so updating the theme through GPM replaces build/css/site.css with whatever the theme shipped. Hit the Compile button or run the CLI after a theme update. An automatic post-update recompile is on the list.

Dynamic classes still need safelisting. A class name that's never written out in full anywhere the scanner looks won't be generated, exactly like real Tailwind. Add the file to safelist_files, add its directory to sources, or use @source inline("bg-{red,blue}-500") in your input CSS. Brace expansion and @source not inline(...) both work.

Get it

Tailwind4 1.0.6 is out now and needs Grav 2.0, which means PHP 8.3 or higher:

BASH
bin/gpm install tailwind4

The source is on GitHub, and the README has the full theme contract reference if you want to wire up your own Tailwind theme. If you build one, or if you hit an engine gap the --diff report turns up, come tell us about it on Discord. Those reports are how the last six releases happened.

Enjoy!

Andy

Related posts

Keep reading