LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

Ruby on Rails and Tailwind CSS

A class sits in the rendered HTML and no rule exists for it. That is the failure mode of Tailwind on Rails, it is silent in every layer that could report it, and the answer is always the same: the string you wrote in Ruby was never in a file in a shape the scanner recognises.

Everything below was run on a scratch application generated by the Rails 8.1.3.1 generator, which resolved gem "rails", "~> 8.1.3" to Rails 8.1.4, on Ruby 4.0.5 and PostgreSQL on port 15432, with tailwindcss-rails 4.6.0, tailwindcss-ruby 4.3.3 and propshaft 1.3.2, on an Apple M2 Max running macOS arm64-darwin25. The Tailwind CLI reports itself as tailwindcss v4.3.3. Timings come from bin/rails tailwindcss:build[verbose], which sets DEBUG=1 and makes the CLI print its own phase breakdown.

What --css=tailwind puts in the application

Four files and one gem. bin/rails tailwindcss:install printed this:

  Add Tailwindcss container element in application layout
      insert    app/views/layouts/application.html.erb
  Build into app/assets/builds
      create    app/assets/builds
      create    app/assets/builds/.keep
  Add default .../app/assets/tailwind/application.css
      create    app/assets/tailwind/application.css
  Add default Procfile.dev
      create    Procfile.dev
  Ensure foreman is installed
         run    gem install foreman from "."
Successfully installed foreman-0.90.0
  Add bin/dev to start foreman
       force    bin/dev
  Compile initial Tailwind build
         run    rails tailwindcss:build from "."
≈ tailwindcss v4.3.3

Done in 87ms

The entire configuration file is one line:

@import "tailwindcss";

There is no tailwind.config.js, and in v4 there is nowhere to put one. Everything that used to live in that file now lives in this one, which is the single biggest difference between what you will find in a two year old Stack Overflow answer and what is on disk.

The compiler is not Node. tailwindcss-ruby ships a platform binary and the gem execs it:

$ file .../tailwindcss-ruby-4.3.3-arm64-darwin/exe/arm64-darwin/tailwindcss
.../tailwindcss: Mach-O 64-bit executable arm64
$ ls -lh .../exe/arm64-darwin/tailwindcss
-rwxr-xr-x@ 1 mehdifarsi  staff    76M Sep 13 15:43 .../tailwindcss
$ otool -L .../exe/arm64-darwin/tailwindcss
    /usr/lib/libicucore.A.dylib
    /usr/lib/libresolv.9.dylib
    /usr/lib/libc++.1.dylib
    /usr/lib/libSystem.B.dylib

76 megabytes of vendored compiler in the bundle, and four system libraries. That is the cost of the no-Node story, and it is paid once per platform in Gemfile.lock, which lists six of them: aarch64-linux-gnu, aarch64-linux-musl, arm64-darwin, x86_64-linux-gnu, x86_64-linux-musl, and the generic fallback.

The build command has no options you configure

Tailwindcss::Commands.compile_command builds the argument list, and both paths are hard-coded:

command = [
  Tailwindcss::Ruby.executable(**kwargs),
  "-i", rails_root.join("app/assets/tailwind/application.css").to_s,
  "-o", rails_root.join("app/assets/builds/tailwind.css").to_s,
]

command << "--minify" unless (debug || rails_css_compressor?)

Note the --minify. It is on in development too, so what you see in devtools is minified unless you set TAILWINDCSS_DEBUG=1 or run bin/rails tailwindcss:build[debug].

Which files the scanner reads

Tailwind v4 removed the content array. Nothing in app/assets/tailwind/application.css declares which files to scan by default, and nothing in the gem passes --cwd, so the scan root is the working directory of whatever process ran the build. For bin/rails that is Rails.root.

Proof that the root is the working directory and not something derived from the input file: running the same binary with the same absolute -i and -o, from app/views instead of from the application root, produced 5099 bytes instead of 5608, and three classes vanished.

$ cd app/views && tailwindcss -i .../app/assets/tailwind/application.css -o /tmp/tw-from-views.css --minify
Done in 25ms
ABSENT  text-emerald-700
ABSENT  text-fuchsia-600
ABSENT  text-lime-500
PRESENT mt-28

The gem's README names the consequence for containers directly: "Without a WORKDIR, tailwind may search the entire filesystem for files with CSS class names." A build that hangs in Docker and finishes in two seconds on a laptop is that, and it is worth checking against your Dockerfile before you go looking at layer caching.

Within that root, one probe file per location, each holding a distinct colour so the result is unambiguous:

Where the literal was In the build?
app/views/layouts/application.html.erb yes
app/helpers/probe_helper.rb, as a constant yes
lib/probe_lib.rb yes
config/probe_config.rb yes
app/javascript/controllers/hello_controller.js, in a comment yes
tmp/probe_tmp.html.erb only with no .gitignore
a directory listed in .gitignore no
node_modules/probe.html no
a gem's view, outside Rails.root no

Two of those rows are the interesting ones. Tailwind honours .gitignore in a directory that is not a git repository at all: the scratch app was generated with --skip-git, has no .git, and the scanner still skipped the path listed in a hand-written .gitignore. And a Ruby comment in a JavaScript file counts, because the scanner is looking for candidate substrings and has no idea what a comment is.

The utilities that came out of the bootsnap cache

The tmp/ row is the one that produces the classic "why is .table in my CSS" question. With the Rails default .gitignore in place the unminified build was 6424 bytes and 15 utilities. With the file removed, 11952 bytes and 37 utilities. Twenty of the twenty-two new ones:

.absolute .block .blur .border .collapse .filter .hidden .invert .invisible
.isolate .italic .lowercase .relative .resize .shadow .sticky .table
.transition .uppercase .visible

Every one of those is an English word that is also a Tailwind utility, and they came out of 11M of compiled Ruby bytecode:

$ grep -rla "isolate" tmp
tmp/cache/bootsnap/load-path-cache
tmp/cache/bootsnap/compile-cache-iseq/0d/35e3e2acd23033

The class that never gets a rule

One helper and one template hold the whole problem. The helper:

def dynamic_badge(color)
  tag.span("x", class: "bg-#{color}-500 rounded-full")
end

The page renders exactly what you would expect:

<span class="bg-red-500 rounded-full">x</span>

And the build, after bin/rails tailwindcss:build:

PRESENT text-indigo-600
PRESENT font-bold
PRESENT text-3xl
PRESENT rounded-full
ABSENT  bg-red-500

rounded-full survives and bg-red-500 does not, from the same string literal in the same file, because rounded-full is present as a contiguous run of characters and bg-red-500 only exists after Ruby has evaluated the interpolation. The scanner reads bytes. It does not run your application, it does not know color is one of three values, and it will never know.

This is the failure worth building a guard against, because nothing else reports it. The rendered page is a 200, the CSS file exists, the asset digest is correct, and the element is simply unstyled. An integration test that walks the class attributes of a rendered page and asserts each one has a rule in app/assets/builds/tailwind.css catches it in the suite instead of in a screenshot:

test "every class in the rendered page has a rule in tailwind.css" do
  missing = classes_in(response.body).reject { |c| rule_for?(c) }
  assert_equal [], missing, "rendered but absent from #{BUILD}: #{missing.inspect}"
end

private
  def classes_in(html)
    html.scan(/class="([^"]*)"/).flatten.flat_map(&:split).uniq.sort
  end

  def rule_for?(klass)
    @css.include?(".#{klass.gsub(/([:.\/\[\]])/) { "\\#{$1}" }}")
  end

The gsub is not decoration. Tailwind escapes the selector, so sm:mt-0 is .sm\:mt-0 and px-3.5 is .px-3\.5 in the output, and a naive include? reports every responsive variant as missing.

@source inline is what replaced safelist

The v3 answer to the interpolation problem was a safelist array in tailwind.config.js. That config file does not exist in v4 and neither does the key. The replacement is a directive in the CSS file, and it takes brace expansion:

@source inline("bg-{red,green,blue}-500");

After that build, bg-red-500, bg-green-500 and bg-blue-500 all have rules, and bg-purple-500 does not. bg-green-500 appears in no template anywhere in the application.

The cost is that the list is now a second place where the colour set is written down, in a language that cannot see the Ruby constant it is duplicating. The application serving this page carries the comment that goes with that trade, which is the only thing that makes it survivable:

/* Quiz topic tints and Yield cover tints are picked in Ruby (QuizAnswersHelper::TOPIC_BADGE_CLASSES,
   YieldHelper::COVER_TINTS), so pin them here rather than trusting source detection to reach into
   the helpers - a missing tint fails silently as an unstyled white block. */
@source inline("bg-red-300 bg-pink-300 bg-orange-200 bg-gray-200 bg-violet-200 bg-lime-200 bg-cyan-200 bg-pink-200");

Your test file is a source file

The refute rule_for?("bg-purple-500") in the test above failed the first time it ran, and the failure was correct. test/ is inside Rails.root, it is not in .gitignore, so the scanner read test/integration/tailwind_coverage_test.rb, found the literal bg-purple-500 in the assertion, and generated .bg-purple-500{background-color:var(--color-purple-500)}. The test asserting a class was absent is what made it present.

$ grep -rn "bg-purple-500" app lib config test package.json
app/assets/builds/tailwind.css:2:...
test/integration/tailwind_coverage_test.rb:24:    refute rule_for?("bg-purple-500")

The fix is one line in the CSS file, and it is worth having whether or not you write this particular test, because fixture files and system test helpers are full of class names that no longer exist in any view:

@source not "../../../test";

With that line the build drops the class and the suite goes green:

5 runs, 15 assertions, 0 failures, 0 errors, 0 skips

Configuration is CSS now

@theme is where theme.extend went. Custom tokens become CSS custom properties and utilities in the same pass:

@theme {
  --color-launch: oklch(0.72 0.19 45);
  --font-display: "Berkeley Mono", ui-monospace, monospace;
  --spacing-gutter: 5.5rem;
}

produces, in app/assets/builds/tailwind.css:

--color-launch:oklch(72% .19 45)
.bg-launch{background-color:var(--color-launch)}
.font-display{font-family:var(--font-display)}
.mt-gutter{margin-top:var(--spacing-gutter)}

The namespace prefix decides which utility family the token joins: --color-* gives you bg-, text- and border-, --spacing-* gives you mt-, p- and the rest. Note that the CLI normalised oklch(0.72 0.19 45) to oklch(72% .19 45) on the way out, so a string comparison against what you wrote will not match what shipped.

Three ways to run the watcher, and what the loop costs

bin/dev and its Procfile.dev are the generated default:

web: bin/rails server
css: bin/rails tailwindcss:watch

The Puma plugin is the option that does not need foreman. One line in config/puma.rb:

plugin :tailwindcss if ENV.fetch("RAILS_ENV", "development") == "development"

and bin/rails server alone spawns the watcher as a child process:

$ ps -ax -o pid,command | grep tailwindcss
26972 ruby bin/rails tailwindcss:watch
26974 .../exe/arm64-darwin/tailwindcss -i .../application.css -o .../tailwind.css --minify -w

Measured latency from writing a new class into a template to that class being readable in app/assets/builds/tailwind.css, five runs, polling the output file every 5ms on the 65-template application described below: 13.8, 62.9, 79.1, 62.6, 68.3 ms. The CLI's own accounting for those rebuilds is Done in 2ms, so essentially all of it is filesystem event latency, not compilation. The initial build when the watcher starts was 226ms.

The browser side works because the digest changes. Adding text-yellow-300 to a template moved the asset URL from tailwind-688572c1.css to tailwind-b7bc32e1.css, which is what makes data-turbo-track="reload" fire and what makes the cache-control: public, max-age=31536000, immutable on the asset safe. Propshaft computes that digest as Digest::SHA1.hexdigest("#{content_with_compile_references}#{load_path.version}").first(8).

One detail in the generated layout that is easy to misread:

<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>

:app is a Propshaft bulk form meaning every CSS file under app/assets, so that single tag emitted two links, in alphabetical order:

<link rel="stylesheet" href="/assets/application-8b441ae0.css" data-turbo-track="reload" />
<link rel="stylesheet" href="/assets/tailwind-f0f13f10.css" data-turbo-track="reload" />

Tailwind's output is second, so it wins ties against anything you put in app/assets/stylesheets/application.css. That ordering is alphabetical luck, not design, and renaming a stylesheet can reverse it.

bin/rails test builds your CSS and bin/rails test <path> does not

The gem attaches the build to test:prepare:

Rake::Task["assets:precompile"].enhance(["tailwindcss:build"])

if Rake::Task.task_defined?("test:prepare")
  Rake::Task["test:prepare"].enhance(["tailwindcss:build"])

This section said the opposite for most of the time it existed. The reasoning was that bin/rails test is a Thor command rather than the rake task, so it would never reach test:prepare, and the error below looked like proof. It is not proof, because of which command produced it. What actually decides is the argument you pass, at railties-8.1.4/lib/rails/commands/test/test_command.rb:32:

Rails::TestUnit::Runner.parse_options(args)
run_prepare_task if self.args.none?(EXACT_TEST_ARGUMENT_PATTERN)
Rails::TestUnit::Runner.run(args)

EXACT_TEST_ARGUMENT_PATTERN is /^-n|^--name\b|#{Rails::TestUnit::Runner::PATH_ARGUMENT_PATTERN}/, and PATH_ARGUMENT_PATTERN at rails/test_unit/runner.rb:27 is %r"^(?!/.+/$)[.\w]*[/\\]", which is "contains a slash and is not a /regex/". Name a file, or pass -n, and the prepare task is skipped. With the build deleted:

$ rm app/assets/builds/tailwind.css
$ bin/rails test test/integration/tailwind_coverage_test.rb
Errno::ENOENT: No such file or directory @ rb_sysopen - .../app/assets/builds/tailwind.css
    test/integration/tailwind_coverage_test.rb:7:in 'Pathname#read'
5 runs, 0 assertions, 0 failures, 5 errors, 0 skips
$ ls app/assets/builds/tailwind.css
ls: app/assets/builds/tailwind.css: No such file or directory

bin/rails test -n "/theme/" behaves the same way, 1 runs, 0 assertions, 0 failures, 1 errors, file still absent. The bare command, on the same deleted file, rebuilt it before the first test ran:

$ bin/rails test
≈ tailwindcss v4.3.3

Done in 48ms
Running 75 tests in parallel using 12 processes

The consequence is backwards from what you want. The full-suite run, which in CI has usually just precompiled anyway, pays for a rebuild. The single-file run, the one you do repeatedly while working on one system test, is the one that quietly uses whatever CSS was last written, and a system test against stale CSS screenshots an unstyled page and passes.

Production is the same command, one step earlier

assets:precompile is enhanced with tailwindcss:build, so the ordering is guaranteed: Tailwind writes the file, then Propshaft digests it.

$ RAILS_ENV=production bin/rails assets:precompile
≈ tailwindcss v4.3.3

Done in 39ms
Writing tailwind-b7bc32e1.css
Writing application-8b441ae0.css

Both copies are 6664 bytes, app/assets/builds/tailwind.css and public/assets/tailwind-b7bc32e1.css.

What it costs

Scan time is a function of how many files are under the working directory, and the Rails convention that hides the worst case is bundle config set path vendor/bundle. That directory is not in the Rails default .gitignore. On the ten-scaffold application used here, 65 ERB templates and 188 files across app, lib, config, db and test, against 9968 files and 307M in vendor/bundle:

scan total build minified output utilities
@source not "../../../vendor" 9.15 / 8.78 / 8.80 ms 39.23 / 37.79 / 38.84 ms 13,017 bytes 81
vendor scanned 875.13 / 792.79 / 872.31 ms 985.05 / 905.03 / 980.70 ms 26,393 bytes 182

Three consecutive warm runs each. The second row is not only slower, it is wrong: half the output is utilities generated out of gem documentation.

.\[mailto\:gregory\.t\.brown\
.\[rdoc-ref\:BigDecimal\
.\[rdoc-ref\:doc\/glossary\.rdoc\]
.\[ruby-dev\:28445\]

Tailwind's arbitrary value syntax reads [rdoc-ref:BigDecimal] in an RDoc comment as a candidate, and there is no mechanism by which it could know better. One line of CSS fixes it.

For scale at the other end: the application serving this page has 226 ERB templates and builds in 173.68 / 165.82 / 130.58 ms with 98.61 / 86.83 / 57.99 ms of that in the scan, producing 132,261 bytes minified. That is the shape to expect. A build over a second means something in the tree is being scanned that should not be.

The other cost is plugins. The binary needs no Node runtime, but @plugin resolves from node_modules, so a plugin reintroduces the JavaScript toolchain you thought you had escaped:

$ bin/rails tailwindcss:build
≈ tailwindcss v4.3.3

Error:
│ Error: Can't resolve 'daisyui' in '.../app/assets/tailwind'

After npm add daisyui (daisyui 5.7.46, node v26.4.0, npm 11.17.0), 3.7M of node_modules, the build went from 39ms to 114.98ms and the output from 13,017 to 32,864 bytes. node_modules itself is not scanned for candidates, which is worth knowing before you go adding exclusions for it: a probe file placed inside it produced no rule.

Gems ship views the scanner cannot see

A gem's templates live outside Rails.root, so none of their classes are in your build. A path gem with <div class="bg-teal-400 tracking-widest"> in a partial contributed nothing until the CSS file named it:

@source "../../../../probe_engine/app";

The path is relative to the CSS file, which means a real gem needs either an absolute path that differs per machine or a bundle config set path vendor/bundle layout that puts the gems inside the tree, with the scan cost in the table above. Neither is good. tailwindcss-rails has an experimental third route for engines that ship an app/assets/tailwind/<engine_name>/engine.css, generating an import stub under app/assets/builds/tailwind/, but it only helps engines written to cooperate.

What this page does not cover

No browser rendering was checked. Every claim here is about which bytes are in app/assets/builds/tailwind.css and which are not, which is the layer where the silent failures live, and it says nothing about whether the resulting page looks right.

Not covered either: upgrading an existing v3 application, where the interesting work is the tailwind.config.js to @theme migration and the renamed utilities rather than anything in this gem; cssbundling-rails and the PostCSS route, which is where you go if you need PostCSS at all since the v4 CLI dropped --postcss; Sprockets, since this was all Propshaft; and dark mode, container queries and the rest of what Tailwind itself does, which is documentation, not Rails.

The class organisation question, whether the repetition goes in a partial or a ViewComponent or a @apply rule, is deliberately absent. It is a real argument and it has nothing to do with the build.

#rails #tailwind #assets

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.