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.
Comments
No comments yet. Be the first.