Maintaining a consistent UI in a growing Rails application is a classic challenge. We often start with the best intentions, clean HTML and utility classes, but as the app scales, we inevitably fall into "UI boilerplate fatigue." Whether it’s copy-pasting the same "Avatar with initials" logic across dozens of views or reinventing the wheel for every animated toast, this duplication slowly erodes our development velocity.

To solve this, I recently migrated Calcpace, a running and cycling tracker built with Rails 8, to jet_ui, JetRockets’ component library. In this article, I’ll show you how we used Calcpace as a real-world playground to standardize our interface, leverage Tailwind CSS v4, and solve the production "gotchas" that often come with gem-based assets

The Problem: Copy-Paste Debt

Before the migration, our UI was functional but repetitive. Handling profile pictures required manual conditional logic for avatars and initials in every view:
# Before: UI logic leaking into views
<% if current_profile.avatar.attached? %>  
  <%= image_tag current_profile.avatar.variant(resize_to_fill: [24, 24]), class: "w-6 h-6 rounded-full" %>
<% else %>  
  <div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-xs">    
    <%= current_profile.initials %> 
  </div>
<% end %>

This "inline Tailwind" approach lacks a single source of truth. Changing a border radius meant a tedious search-and-replace across the entire codebase.

The Strategy: Incremental Migration


One of the biggest concerns when adopting a component library is the "big bang" rewrite. Do you have to change every view at once? Absolutely not.

`jet_ui` is designed for incremental adoption. In Calcpace, we didn't touch our legacy views initially. We started by replacing the most "noisy" elements—flashes and avatars—and then moved to complex data tables. You can have a page powered entirely by `jet_ui` components sitting right next to a legacy ERB view using plain Tailwind utility classes. They coexist perfectly because `jet_ui` respects your existing Tailwind configuration while providing the structure of ViewComponent. This removes the psychological barrier of migration: you can improve your app one component at a time.

Requirements and Plugging in Jet UI


`jet_ui` is built on ViewComponent and Tailwind CSS v4. It follows a "Rails-native" philosophy, leveraging the latest tools in the ecosystem:

- Ruby >= 3.0 and Rails >= 7.0 (Calcpace runs on Rails 8.1).
- Tailwind CSS v4 (via `tailwindcss-rails >= 4.x`).
- Stimulus and Turbo (standard in modern Rails).

In your `Gemfile`:
gem "view_component"
gem "jet_ui"

The Power of Generators


One of jet_ui's standout features is its suite of generators. They don’t just copy files; they wire up your entire application.

jet_ui:install

This sets up the library in your application (CSS + JS). It is safe to re-run after gem upgrades, as already-configured steps are automatically skipped:

# Install or update
rails generate jet_ui:install

jet_ui:eject

If you need to customize a component beyond standard options, you can "eject" it. This copies the Ruby class, ERB template, and Stimulus controller directly into your `app/components/jet_ui/` folder. The ejected files take precedence automatically.

You can eject multiple components at once and use flags to keep your codebase lean:
# Eject button, card, and flash
rails generate jet_ui:eject btn card flash

# Skip specific files if you only want to customize the template
rails generate jet_ui:eject btn --skip-test --skip-preview
rails generate jet_ui:eject flash --skip-javascript

Production Readiness: The Vendoring Strategy


While the `install` generator works perfectly for local development by pointing to the gem's path, production environments like Docker or CI require a more portable approach. To ensure a deterministic build and clean logs, we adopt a Vendoring strategy. Instead of relying on absolute filesystem paths that change between environments, we copy the CSS directly into the repository but place it outside the standard Rails asset search path to avoid duplicate serving.

1. Vendor the assets programmatically:
mkdir -p vendor/stylesheets   
cp -r $(bundle show jet_ui)/app/assets/stylesheets/* vendor/stylesheets/

2. Update your Tailwind source file:
/* app/assets/tailwind/application.css */   
@import "tailwindcss";   
@import "../../../vendor/stylesheets/jet_ui.css";

Why this approach?
- Portability: The build works in Docker, CI, and any developer's machine without modifications.
- Clean Logs: By placing files in `vendor/stylesheets` (which Propshaft ignores by default), the asset pipeline won't try to serve individual component files (like `popover.css`). This prevents the "404 Not Found" noise in production logs for files already bundled into your main CSS.

Customizing the Theme with Tailwind v4


`jet_ui` uses modern CSS variables. Instead of overriding thousands of utility classes, you update the theme variables in your CSS source:
@theme {  
  --accent-hue: 163;      /* Calcpace Emerald */  
  --accent-chroma: 0.2;  
  --accent-lightness: 0.52;
}

Every component, from buttons to focus rings, will now use your custom palette.

Replacing the Noise: Real-World Examples


Interactive Form Groups

We used `jet_ui.group` to standardize selectors. For our activity unit toggle (KM/MI), the component handles the styling and layout, leaving us with a clean DSL:
<%= jet_ui.group do %>  
  <%= form.radio_button :unit, "km", checked: true %>  
  <%= form.radio_button :unit, "mi" %>
<% end %>

Advanced Composition: Tables and Tabs

The World Records page was our "stress test" for displaying dense data. By composing `tabs`, `card`, and `table`, we reduced a complex view to a readable DSL:
<%= jet_ui.tabs do %>  
  <%= jet_ui.tabs_item "KM", href: records_path(unit: "km"), active: @unit == "km" %>  
  <%= jet_ui.tabs_item "MI", href: records_path(unit: "mi"), active: @unit == "mi" %>
<% end %>

<%= jet_ui.card class: "overflow-hidden" do %>  
  <%= jet_ui.table hovered: true do %>    
    <%= jet_ui.table_thead do %>      
      <%= jet_ui.table_tr do %>        
        <%= jet_ui.table_th { "Event" } %>        
        <%# ... %>      
      <% end %>    
    <% end %>  
  <% end %>
<% end %>

This composition proves the architectural leverage: we get a professional data grid with integrated navigation, all following the same design system with zero manual CSS. It shows that `jet_ui` isn't just for simple widgets, but for the core, data-rich parts of your application.

World Records table using jet_ui
World Records table using jet_ui

Why jet_ui? Comparing with alternatives


You might ask: "Why not just use Flowbite, shadcn-rails, or RailsUI?" 

While those are great tools, they occupy different niches. Flowbite is fantastic for Tailwind-first projects but isn't built as a first-class ViewComponent library, often requiring you to wrap their HTML yourself. shadcn-rails follows the "copy-paste" philosophy which is great for total control, but lacks a clean, gem-based upgrade path for those who want their design system managed as a dependency. RailsUI is a premium, template-oriented solution that is excellent for rapid prototyping but might feel too opinionated for existing apps.

`jet_ui` sits in the "Goldilocks" zone: it's ViewComponent-native, Tailwind v4-native, and gem-distributed with an "eject-on-demand" safety valve. You get the maintenance benefits of a gem with the flexibility of local code when you need it.

Conclusion

Migration isn't just about "fancy" code. It's about reducing cognitive load. Developers can focus on building features using high-level components rather than wrestling with low-level utility classes in every single view. If you're building a modern Rails app, `jet_ui` is the bridge between the flexibility of Tailwind and the structure of a professional design system.

Links

🏃 Calcpace

Share: