Tailwind Form Builder

Why use a Tailwind form builder?

Tailwind is already the first choice CSS framework for many Rails developers, it's incredibly easy to add to a project with the the --css=tailwind flag of the rails new command.

However, as you start to build out your app you'll find that you're repeating class names a lot. If you then want to change a shared style you'll have to go through and update all of those class names across the entire app.

Tailwind recommends you use partials and components for this but Rails doesn't have a native component solution and partials are a bit high-ceremony.

Often in small to medium sized apps the bulk of the app is managable directly in the views, but forms become a problem much more quickly.

Gems like Simple Form do a decent job of solving this but require significant configuration and are not always flexible enough for more complex forms.

All Rails forms use a form builder (by default ActionView::Helpers::FormBuilder) which we can replace with our own, either at the app level or on a per-form basis. This allows us to override all the rails form methods like text_field and select and add our own classes and markup.

This gives us nice clean ERB form templates while giving us full control of the markup and classes, plus we keep our app dependency free.

What you'll build

Our tailwind form builder will be a simple class that inherits from ActionView::Helpers::FormBuilder and provides the following functionality:

App setup

We start with the pattern_base1 base app which can also be created with the following commands:

APP_NAME=tailwind-form-builder-app
rails new $APP_NAME -d postgresql -c tailwind --skip-jbuilder --skip-test
cd $APP_NAME
git add -A && git commit -m "rails new $APP_NAME -d postgresql --skip-jbuilder --skip-test"

Let's create a simple Post scaffold to work with, and install Active Storage so we can add a file field to our form:

rails g scaffold post title:string body:text published:boolean
bin/rails active_storage:install
rails db:migrate

Let's add a few validations and the file field to the Post model so we can test error states and file uploads:

# app/models/post.rb

class Post < ApplicationRecord
  validates :title, presence: true
  validates :body, presence: true
  validates :published, inclusion: { in: [true] }

  has_one_attached :file
end

You may also want to skip the scaffold and use a model from your own app, or create a new model with a few fields to work with.

Now we're going to set our form builder as the default form builder app-wide. If you want to switch to the native builder for any specific form you can pass builder: ActionView::Helpers::FormBuilder. Alternatively you could do the reverse: skip the app-wide configuration and set our app builder on a per-form basis by passing builder: FormBuilders::TailwindFormBuilder to the form_with helper.

The other thing we want to do is replace the default field error proc so that we can remove the extra markup Rails adds by default as we add our own error markup. Unfortunately this can only be set at an app level so this may cause problems if you intend to use both builders alongside each other and want to keep the default error markup in the original builder.

See the Rails docs for more information on both of these config options.

# config/initalizers/forms.rb
Rails.application.config.action_view.default_form_builder = "FormBuilders::TailwindFormBuilder"
Rails.application.config.action_view.field_error_proc = Proc.new { |html_tag, instance| html_tag }

Creating the form builder

First we're going to install the tailwind-merge gem which allows to concatenate strings of tailwind classes together with the later classes replacing the earlier ones if they conflict. There's a brief explanation of how that works on the gem page or watch this video for more information.

bundle add tailwind-merge

It's probably worth taking a quick look at the Rails custom form builder guides, Rails FormBuilder API docs and the FormBuilder source code before we get started, but it's not strictly necessary.

Let's start with a minimal form builder, and build it up from there.

# app/lib/form_builders/tailwind_form_builder.rb

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    class_attribute :text_field_helpers, default: field_helpers - %i[label check_box radio_button fields_for fields hidden_field file_field]

    text_field_helpers.each do |field_method|
      class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
        def #{field_method}(method, options = {})
          if options.delete(:native)
            super
          else
            text_like_field(#{field_method.inspect}, method, options)
          end
        end
      RUBY_EVAL
    end

    def text_field_classes
      "w-full rounded-lg font-light text-md lg:text-sm disabled:bg-#{gray_color}-50 disabled:text-#{gray_color}-500"
    end

    def border_classes
      "border-0 shadow-sm ring-1 ring-inset ring-#{gray_color}-300 focus:ring-2 focus:ring-inset focus:ring-#{primary_color}-600"
    end

    private

    def text_like_field(field_method, object_method, options = {})
      classes = tailwind_classes(text_field_classes, border_classes, options[:class])

      send(field_method, object_method, options.merge(native: true, class: classes))
    end

    def tailwind_classes(*args)
      TailwindMerge::Merger.new.merge([*args].join(' '))
    end

    def primary_color
      self.options[:primary_color] || "indigo"
    end

    def secondary_color
      self.options[:secondary_color] || "pink"
    end

    def gray_color
      self.options[:gray_color] || "slate"
    end

    def error_color
      self.options[:error_color] || "red"
    end
  end
end

Here we override all the text type helpers, i.e. text_field, email_field, password_field, etc. with a bit of metaprogramming and add our own Tailwind merged classes to the class option. We also add a native option which allows us to revert to the native rails form builder for a single field.

Now we need to make some changes to our Tailwind config, firstly to add the form builder to the list of content files so that the Tailwind CSS is recompiled when we make changes, and because the tailwind compiler won't pick up dynamic classes like ring-#{gray_color}-300 we need to add them to the safelist with their variants. See the Tailwind docs for more information.

// config/tailwind.config.js

const defaultTheme = require("tailwindcss/defaultTheme");

module.exports = {
  content: ["./app/lib/form_builders/**/*.rb"],
  safelist: [
    {
      pattern: /\!?(bg|text|ring|border)-(indigo|slate|red)-(\d00|50|950)/,
      variants: [
        "hover",
        "focus",
        "active",
        "disabled",
        "focus-visible",
        "file",
      ],
    },
  ],
};

Next we'll add error states for form elements and an error message helper.

# app/lib/form_builders/tailwind_form_builder.rb

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    def error_label(object_method, options)
      return if errors_for(object_method).blank?

      error_message = @object.errors.full_messages_for(object_method).join(", ").html_safe

      label(object_method, error_message, class: "text-red-500 text-sm")
    end

    def error_messages(options = {})
      return if object.errors.none?

      @template.tag.div id: "error_explanation", class: error_message_classes do
        @template.concat @template.tag.h2 "#{@template.pluralize(object.errors.count, "error")} prohibited this #{object.class.name.downcase} from being saved:"

        @template.concat(
          @template.tag.ul do
            object.errors.full_messages.each do |message|
              @template.concat @template.tag.li(message)
            end
          end
        )
      end
    end

    def error_message_classes
      "bg-red-50 text-red-500 px-3 py-2 text-sm font-light rounded-lg #{vertical_gap}"
    end

    def error_classes(field_method, object_method)
      return "" if errors_for(object_method).empty?

      case field_method
      when :check_box, :radio_button then "border-#{error_color}-300 focus:ring-#{error_color}-600"
      else "ring-#{error_color}-300 focus:ring-#{error_color}-600"
      end
    end

    private

    def text_like_field(field_method, object_method, options = {})
      layout = options.key?(:layout) ? options.delete(:layout) : form_layout
      classes = tailwind_classes(text_field_classes, border_classes, error_classes(field_method, object_method), options[:class])

      layout_for_if(layout, field_method, object_method, options.reverse_merge({})) do
        send(field_method, object_method, options.merge(native: true, class: classes))
      end
    end

    def errors_for(object_method)
      return if @object.blank? || object_method.blank?

      @object.errors[object_method]
    end
  end
end

Now let's add a layout option to the form, defaulted to true, and add a conditional wrapper around all the fields. The form builder provides a @template object which is the view context, so we can use that to render the label and error messages using the helpers we created in the previous step.

# app/lib/form_builders/tailwind_form_builder.rb

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    attr_reader :form_layout

    def initialize(object_name, object, template, options)
      @form_layout = options.key?(:layout) ? options.delete(:layout) : true

      super
    end

    def label(method, text = nil, options = {}, &block)
      return super if options.delete(:native)

      classes = tailwind_classes(label_classes, options.delete(:class))

      super(method, text, options.merge(class: classes), &block)
    end

    def label_classes
      "block text-sm leading-6 text-#{gray_color}-700"
    end

    private

    def layout_for_if(layout, field_method, object_method, options = {})
      return yield unless layout

      layout_for(field_method, object_method, options) do
        yield
      end
    end

    def layout_for(field_method, object_method, options = {})
      label_options = options.delete(:label) || {}
      label_text = label_options.delete(:text)
      all_label_classes = tailwind_classes(label_classes, label_options.delete(:class))
      label_options.merge!(class: all_label_classes)

      if field_method.in? %i[check_box radio_button]
        @template.tag.div(class: "#{vertical_gap}") do
          @template.concat(
            @template.tag.div(class: "flex flex-row items-center") do
              @template.concat yield
              @template.concat label(object_method, label_text, label_options)
            end
          )
          @template.concat error_label(object_method, options)
        end
      else
        @template.tag.div class: "flex flex-col #{vertical_gap}" do
          @template.concat label(object_method, label_text, label_options)
          @template.concat yield
          @template.concat error_label(object_method, options)
        end
      end
    end
  end
end

Finally we'll add helpers for the other form elements.

# app/lib/form_builders/tailwind_form_builder.rb

module FormBuilders
  class TailwindFormBuilder < ActionView::Helpers::FormBuilder
    def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
      return super if options.delete(:native)

      layout = options.key?(:layout) ? options.delete(:layout) : form_layout
      classes = tailwind_classes(check_box_classes, error_classes(:check_box, method), options[:class])

      layout_for_if(layout, :check_box, method, options.reverse_merge({})) do
        super(method, options.merge(class: classes), checked_value, unchecked_value)
      end
    end

    def radio_button(method, tag_value, options = {})
      return super if options.delete(:native)

      layout = options.key?(:layout) ? options.delete(:layout) : form_layout
      classes = tailwind_classes(radio_button_classes, error_classes(:radio_button, method), options[:class])

      layout_for_if(layout, :radio_button, method, options.reverse_merge({})) do
        super(method, tag_value, options.merge(class: classes))
      end
    end

    def file_field(method, options = {})
      return super if options.delete(:native)

      layout = options.key?(:layout) ? options.delete(:layout) : form_layout
      classes = tailwind_classes(file_field_classes, error_classes(:file, method), border_classes, error_classes(:file, method), options[:class])

      layout_for_if(layout, :file_field, method, options.reverse_merge({})) do
        super(method, options.merge(class: classes))
      end
    end

    def select(method, choices = nil, options = {}, html_options = {}, &block)
      return super if options.delete(:native)

      layout = options.key?(:layout) ? options.delete(:layout) : form_layout
      classes = tailwind_classes(select_classes, error_classes(:select, method), border_classes, error_classes(:select, method), html_options.delete(:class))

      layout_for_if(layout, :select, method, options.reverse_merge({})) do
        super(method, choices, options, html_options.merge(class: classes), &block)
      end
    end

    def label(method, text = nil, options = {}, &block)
      return super if options.delete(:native)

      classes = tailwind_classes(label_classes, options.delete(:class))

      super(method, text, options.merge(class: classes), &block)
    end

    def submit(value = "Save changes", options = {})
      return super if options.delete(:native)

      classes = tailwind_classes(button_classes, options.delete(:class))

      super(value, { data: { turbo_submits_with: "Submitting..." } }.deep_merge(options.merge(class: classes)))
    end

    def button(value = nil, options = {}, &block)
      return super if options.delete(:native)

      classes = tailwind_classes(button_classes, options.delete(:class))

      super(value, options.merge(class: classes), &block)
    end

    def check_box_classes
      "h-4 w-4 rounded border-#{gray_color}-300 text-#{primary_color}-600 focus:ring-#{primary_color}-600 #{horizontal_gap}"
    end

    def radio_button_classes
      "h-4 w-4 border-#{gray_color}-300 text-indigo-600 focus:ring-indigo-600 #{horizontal_gap}"
    end

    def file_field_classes
      tailwind_classes(
        "block w-full text-sm text-#{gray_color}-900 rounded-lg cursor-pointer bg-#{gray_color}-50 focus:outline-0",
        "file:mr-4 file:py-2 file:px-4 file:rounded-l-lg file:border-0 file:text-sm file:font-semibold file:bg-#{primary_color}-500 file:text-white hover:file:bg-#{primary_color}-400 hover:file:cursor-pointer"
      )
    end

    def select_classes
      "block w-full rounded-md py-1.5 pl-3 pr-10 text-#{gray_color}-900 sm:text-sm sm:leading-6"
    end

    def button_classes
      "w-full justify-center block rounded-md bg-#{primary_color}-500 py-2 px-3 text-center text-sm font-semibold text-white shadow-sm hover:bg-#{primary_color}-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-#{primary_color}-500 cursor-pointer disabled:opacity-60 disabled:pointer-events-none #{vertical_gap}"
    end
  end
end

To give it a trial on our post model form let's change it like so:

<!-- app/views/posts/_form.html.erb -->

<%= form_with(model: post, class: "mt-3", primary_color: "rose") do |form| %>
  <% # Error messages go at the top %>
  <%= form.error_messages %>

  <% # Here we show overriding the label text and adding a class to it %>
  <%= form.text_field :title, placeholder: "Title", label: { text: "Something else", class: 'text-green-700' } %>

  <%= form.text_area :body, rows: 4 %>

  <%= form.check_box :published %>

  <%= form.radio_button :published, true %>

  <%= form.file_field :file %>

  <%= form.select :title, ['Option 1', 'Option 2'], include_blank: true %>

  <% # Test rendering a field without layout %>
  <%= form.text_field :title, layout: false %>

  <% # Test rendering a native rails field %>
  <%= form.text_field :title, native: true %>

  <%= form.submit %>

  <%= form.button "Cancel" %>
<% end %>

And that's it, you can see the full class below in the diff.

Testing

Test framework setup

We use our standard test framework setup layer which includes RSpec, FactoryBot, Cuprite and Capybara, based on the Evil Martians system of a test.

In addition we're going to add the RSpec HTML Matchers Gem as it allows us to test for the presence of HTML elements and their attributes in a more readable way than the standard RSpec matchers.

bundle add rspec-html-matchers --group "test"
# spec/support/html_matchers.rb

RSpec.configure do |config|
  config.include RSpecHtmlMatchers
end

Factories

We just want a basic post model factory to test this example

# spec/factories/posts.rb

FactoryBot.define do
  factory :post do
    title { "MyString" }
    body { "MyText" }
    published { false }
  end
end

Helper test

We don't want to test every available option in the form builder, so we're aiming here for a rough test that covers the main functionality and gives us confidence that the form builder is working as expected. First we test the whole builder:

Then we test the individual form elements:

As you can see the RSpec HTML matchers allow us to create DRY semantic tests that are easy to read and understand.

# spec/helpers/tailwind_form_builder_spec.rb

require 'rails_helper'

RSpec.describe FormBuilders::TailwindFormBuilder, type: :helper do
  let(:object) { build(:post) }
  let(:builder) { described_class.new(:post, object, self, {}) }

  describe 'form' do
    context 'by default' do
      subject do
        form_with(model: object, builder: FormBuilders::TailwindFormBuilder) do |form|
          concat form.text_field(:title)
          concat form.submit
        end
      end

      it 'returns a styled form with layout by default' do
        expect(subject).to have_tag('form') do
          with_tag('div', with: { class: 'flex' }) do
            with_tag('input:last-child', with: { type: 'text', class: 'ring-1' })
          end
          with_tag('input', with: { type: 'submit', value: 'Save changes', class: 'w-full', 'data-turbo-submits-with': 'Submitting...' })
        end
      end
    end

    context 'when the layout option is false' do
      subject do
        form_with(model: object, builder: FormBuilders::TailwindFormBuilder, layout: false) do |form|
          concat form.text_field(:title)
          concat form.submit
        end
      end

      it 'returns a styled form without layout wrapper' do
        expect(subject).not_to have_tag('div')
        expect(subject).not_to have_tag('label')
        expect(subject).to have_tag('input', with: { type: 'text', class: 'ring-1' })
      end
    end

    context 'when the primary color is set to green' do
      subject do
        form_with(model: object, builder: FormBuilders::TailwindFormBuilder, primary_color: 'green') do |form|
          concat form.text_field(:title)
          concat form.submit
        end
      end

      it 'returns a styled form with green primary color' do
        expect(subject).to have_tag('form') do
          with_tag('input', with: { class: 'focus\:ring-green-600' })
          with_tag('input', with: { type: 'submit', class: 'bg-green-500 hover\:bg-green-600' })
        end
      end
    end

    context "when the form object has errors" do
      subject do
        object.errors.add(:title, "can't be blank")

        form_with(model: object, builder: FormBuilders::TailwindFormBuilder) do |form|
          concat form.error_messages
          concat form.text_field(:title)
          concat form.submit
        end
      end

      it "returns a styled form with error messages" do
        expect(subject).to have_tag('form') do
          expect(subject).to have_tag('div', with: { class: 'bg-red-50' }) do
            with_tag('h2') { with_text '1 error prohibited this post from being saved:' }
            with_tag('li') { with_text "Title can't be blank" }
          end
        end
      end
    end
  end

  describe 'text field' do
    context 'by default' do
      subject { builder.text_field(:title) }

      it 'returns a styled text field with layout by default' do
        expect(subject).to have_tag('div', with: { class: 'flex' }) do
          expect(subject).to have_tag('label:first-child') { with_text 'Title' }
          expect(subject).to have_tag('input:last-child', with: { type: 'text', class: 'ring-1' })
        end
      end
    end

    context 'when the layout option is false' do
      subject { builder.text_field(:title, layout: false) }

      it 'returns a styled text field without label and layout wrapper' do
        expect(subject).not_to have_tag('div')
        expect(subject).not_to have_tag('label')
        expect(subject).to have_tag('input', with: { type: 'text', class: 'ring-1' })
      end
    end

    context 'when the native option is true' do
      subject { builder.text_field(:title, native: true) }

      it 'returns the native text field without label and layout wrapper' do
        expect(subject).not_to have_tag('div')
        expect(subject).not_to have_tag('label')
        expect(subject).to have_tag('input', with: { type: 'text', class: nil })
      end
    end

    context 'with custom classes' do
      subject { builder.text_field(:title, layout: false, class: 'custom-class') }

      it 'returns a styled input with custom classes' do
        expect(subject).to have_tag('input', with: { class: 'ring-1 custom-class' })
      end
    end

    context 'with custom tailwind class overriding existing tailwind class' do
      subject { builder.text_field(:title, layout: false, class: 'w-auto') }

      it 'returns a styled input with the new class and not the old one' do
        expect(subject).to have_tag('input', with: { class: 'w-auto' })
        expect(subject).not_to have_tag('input', with: { class: 'w-full' })
      end
    end

    context 'with custom label text and classes' do
      subject { builder.text_field(:title, label: { text: 'Custom label', class: 'text-green-700' }) }

      it 'returns a styled text field with custom label text and classes' do
        expect(subject).to have_tag('label', with: { class: 'text-green-700' }) { with_text 'Custom label' }
        expect(subject).not_to have_tag('label', with: { type: 'text', class: 'text-slate-700' })
      end
    end
  end

  describe 'check box' do
    context 'by default' do
      subject { builder.check_box(:published) }

      it 'returns a styled check box with layout by default' do
        expect(subject).to have_tag('div', with: { class: 'flex' }) do
          expect(subject).to have_tag('input:nth-last-child(2)', with: { type: 'checkbox', class: 'rounded' }) # Checkboxes also have the hidden field
          expect(subject).to have_tag('label:last-child') { with_text 'Published' }
        end
      end
    end

    context 'when the layout option is false' do
      subject { builder.check_box(:published, layout: false) }

      it 'returns a styled check box without label and layout wrapper' do
        expect(subject).not_to have_tag('div')
        expect(subject).not_to have_tag('label')
        expect(subject).to have_tag('input', with: { type: 'checkbox', class: 'rounded' })
      end
    end

    context 'when the native option is true' do
      subject { builder.check_box(:published, native: true) }

      it 'returns the native check box without label and layout wrapper' do
        expect(subject).not_to have_tag('div')
        expect(subject).not_to have_tag('label')
        expect(subject).to have_tag('input', with: { type: 'checkbox', class: nil })
      end
    end
  end

  describe 'select' do
    context 'by default' do
      subject { builder.select(:published, %w[Yes No]) }

      it 'returns a styled select with layout by default' do
        expect(subject).to have_tag('div', with: { class: 'flex' }) do
          expect(subject).to have_tag('label:first-child') { with_text 'Published' }
          expect(subject).to have_tag('select:last-child', with: { class: 'ring-1' }) do
            expect(subject).to have_tag('option', with: { value: 'Yes' }, text: 'Yes')
            expect(subject).to have_tag('option', with: { value: 'No' }, text: 'No')
          end
        end
      end
    end
  end

  describe "submit button" do
    context 'by default' do
      subject { builder.submit }

      it "returns a styled submit button" do
        expect(subject).to have_tag("input", with: { type: "submit", value: "Save changes", class: "w-full", "data-turbo-submits-with": "Submitting..." })
      end
    end
  end
end

Todo

Full code example

Full diff