DEV Community

Cover image for Styling Simple Form forms with Tailwind
Matouš Borák for NejŘemeslníci

Posted on

Styling Simple Form forms with Tailwind

During our recent redesign of the admin section of our web, we wanted to set up a system that would allow us to quickly build good looking forms. We like to use the Simple Form gem for our forms, we have been using it for nearly 10 years, and we still appreciate its very succinct but flexible syntax. Also, we style our web with Tailwind and we definitely wanted to leverage it for the new admin pages, too. So, at one point of the redesign effort we had to deal with the ”How do we style our new admin forms with Tailwind?“ task and we wanted to do it in the most reusable way possible and without losing Simple Form's flexibility.

In our previous post we ran through the basics of Rails forms rendering and how custom Rails form builders work. In this post, we will build on that remembering that Simple Form is basically just a custom Rails form builder. We will try to briefly describe what components it uses to render the forms and how to amend them to enable flexible Tailwind styling.

Does Simple Form support Tailwind?

Well, yes and no. Simple Form has no Tailwind-specific configuration baked in currently – the details are discussed in this GitHub issue. However, Simple Form is styling system-agnostic so nothing should stop us if we sprinkled the Simple Form configuration with our set of Tailwind classes for each of the form components. (A ”component“, in Simple Form jargon, is an input, its label, a hint and error message by default and we can define a custom component for anything else if we like.).

And indeed, for simple scenarios, this should work well. Problems arise when we need to divert from the default look for a specific field. To understand that, we need to talk a bit about how Simple Form combines the default classes from the configuration with the custom classes that are meant to override them. For each component (input, label, etc.), Simple Form recognizes a <component>_html hash of options. CSS classes are handled via the :class key of this hash. So, for example, label_html: { class: "custom-class" } represents a way to tell Simple Form that we want a specific input’s label to have the "custom-class" class.

Now, how does this "custom-class" get merged with the default classes that we may have defined for the labels? The answer is that Simple Form simply appends the custom class(es) to the default classes. This may work well for traditional CSS styling where we can easily target arbitrary combinations of elements, ids and classes in the CSS and thus ensure that the specificity of the "custom-class" will be high enough to override the defaults. In Tailwind, most classes have the same specificity and in that situation, according to the CSS cascading rules, the class that appears last in the Tailwind-generated CSS file is the one that wins which is most probably not what we want.

To demonstrate this problem with an example, let’s say we have the following default styles defined for a label in the Simple Form configuration:

# config/initializers/simple_form.rb
config.wrappers :default do |b|
  ...
  b.use :label, class: "font-medium"
  ...
end
Enter fullscreen mode Exit fullscreen mode

This config sets a ”medium“ font weight for our form labels by default. Now, suppose we want a specific input’s label to be bold instead, we might want to try the following naive approach (we’re using the Slim template notation here):

/ some_template.html.slim
= simple_form_for(@user) do |f|
  = f.input :e_mail, label: "Important e-mail", \
                     label_html: { class: "font-bold" }
Enter fullscreen mode Exit fullscreen mode

When we inspect the output HTML, everything seems to be in order – the "font-bold" is properly added to the set of classes generated for the label and to the default class "font-medium":

Generated HTML for our sample form showing the CSS cascade problem

But the label is still not rendered bold! The explanation is that the "font-medium" class happens to be listed below the "font-bold" class in the CSS file generated by Tailwind so, according to the CSS cascade rules, it wins:

The CSS cascade problem

When trying to override default Tailwind classes in Simple Form, we need to be able to not only add new classes but also remove some of the default ones. This is not something that Simple Form supports out of the box so we must help it a bit.

One option is the simple_form_tailwind_css gem by Abe Voelker, mentioned also in the GitHub issue. It uses a combination of default Tailwind styles in the Simple Form configuration with a custom form builder and a few custom inputs. The gem supports replacing the default classes related to error messages with custom ones.

This gem was a great source of inspiration for us but we wanted to add a bit more flexibility to the process of overriding default Tailwind classes so we chose our own custom solution that we’ll further describe below.

Our solution to the problem

Let’s first recap the goals that we wanted to achieve with this solution:

  • we wanted great looking, Tailwind-styled Simple Form forms on our new admin pages by default,
  • but with the option to amend the style of a particular field or its part if needed,
  • from a technical point of view, we wanted the default form style and structure to be defined in a single place in the code base,
  • and we preferred to be able to replace default classes selectively rather than all of them in bulk.

For the visual design and layout of the forms, we chose Tailwind UI, a beautifully crafted set of Tailwind-styled components (more on that in a previous post if you like). Also, inspired by Tailwind UI, we decided to layout our new admin forms on a 12-column grid. That was the relatively easy part.

Coming to a default class overriding API

To handle flexible overriding of the class methods, we explored several options that Simple Form would allow. First, we looked into the conventional way – defining default classes for all form parts in the Simple Form initialization file but in the end overriding them didn’t seem possible without monkey-patching Simple Form or re-defining all input types as custom inputs.

What we wanted to achieve here, was to be able to override the defaults selectively, i.e. we wanted to list the particular classes to remove from the defaults and add the ones that should override them. See the following sample usage for an overridden label style:

= simple_form_for(@user) do |f|
  = f.input :e_mail, label: "Important e-mail", \
                     label_html: { remove_default_class: "font-medium", \
                                   class: "font-bold" }
Enter fullscreen mode Exit fullscreen mode

So, by specifying the :remove_default_class key in the options hash we wanted to selectively remove the named default classes and then the ”standard“ :class key would add the overriding classes. Thus, this API would allow flexible and selective replacement of the form defaults.

In the end, we decided to divert a bit from Simple Form conventions and build a combination of a style-less wrapper configuration and a custom form builder. Let’s show you how.

The style-less wrapper configuration

We decided to put all class defaults not in the Simple Form configuration but into a custom form builder. The configuration then defined a ”wrapper“, i.e. a form layout structure, called :plain, that was intentionally class-less. The main part of it looks like this:

  config.wrappers :plain do |b|
    ...

    b.use :label
    b.wrapper :input_wrapper, tag: :div do |component|
      component.use :input
    end
    b.use :hint,  wrap_with: { tag: :p }
    b.use :error, wrap_with: { tag: :p }
  end
Enter fullscreen mode Exit fullscreen mode

This wrapper can render the following raw form structure:

<form>
 <!-- for each field: -->
 <div> <!-- wrapper -->
   <label>...</label>  <!-- optional label -->
   <div> <!-- input wrapper -->
     <input .../>  <!-- the input itself -->
     <p>...</p>  <!-- optional hint -->
     <p>...</p>  <!-- optional error message -->
   </div>
 <div>
 ...
</form>
Enter fullscreen mode Exit fullscreen mode

A few notes about this structure:

  • the input is wrapped in a div: this is not essential but it makes it easier to add margins or set a flexbox layout for certain types of inputs (namely booleans),
  • all other form components are as basic as it gets, no styling, no wrappers,
  • this configuration allows the raw form layout to be potentially reused for form variants that should look different – we would just have to create a custom form builder for each form type.

The custom form builder

Next, we built a custom form builder that inherits from the default Simple Form builder and that takes care of both defining and overriding the default classes. (Have a look at our previous post if you’re not sure what a Rails form builder is.)

In the custom builder, we re-defined the most important methods to build various types of form components, such as the input or label methods. The methods basically do just a couple of things:

  • they define the default Tailwind classes for the given component (with a bit of programmatic logic applied, if needed),
  • they allow to amend the defaults,
  • and finally they pass the handling to the parent method via super.

Let’s show an example for a label method in the custom builder:

class Builders::AdminFormBuilder < SimpleForm::FormBuilder
  def label(attribute_name, *args, &block)
    options = args.extract_options!.dup

    default_class = "block text-sm font-medium text-gray-700"
    options = arguments_with_updated_default_class(default_class, **options)

    super(attribute_name, *[args.first, options], &block)
  end
end
Enter fullscreen mode Exit fullscreen mode

It works as follows:

  • the label method signature is the same as the one in the default Simple Form builder (we’re only using a named block variable instead of an implicit block),
  • the method extracts the options that are passed to a field via the label_html options hash,
  • it defines the default Tailwind classes for our form labels, including the "font-medium" class,
  • it calls the arguments_with_updated_default_class method to update the hash with overridden classes (more on that below),
  • and it reconstructs the original arguments and passes them to the parent method.

The arguments_with_updated_default_class method is a private method in the builder. It takes the default classes string and a hash of options, such as the label_html hash. From it it removes the classes named in the :remove_default_class key of the options hash and adds the new classes listed in the :class key:

def arguments_with_updated_default_class(default_class, **kwargs)
  kwargs = kwargs.dup
  classes = default_class.to_s

  remove_key = :remove_default_class
  class_key = :class

  if kwargs[remove_key].present?
    classes = (classes.split - kwargs[remove_key].split).join(" ")
    kwargs.delete(remove_key)
  end

  # simple_form sometimes uses array of classes instead of strings
  # so we have to account for that
  if kwargs[class_key].is_a?(Array)
    kwargs[class_key] = kwargs[class_key].map(&:to_s).join(" ")
  end

  kwargs[class_key] = (classes.split + kwargs[class_key].to_s.split)
  kwargs[class_key] = kwargs[class_key].join(" ")

  kwargs
end
Enter fullscreen mode Exit fullscreen mode

The only non-obvious line is hopefully the one with the comment – it is here because we found out that Simple Form uses arrays of strings instead of a space-separated string in certain cases so we had to unify the two.

With these methods in the custom builder, repeating the ”bold label“ example above:

= simple_form_for(@user) do |f|
  = f.input :e_mail, label: "Important e-mail", \
                     label_html: { remove_default_class: "font-medium", \
                                   class: "font-bold" }
Enter fullscreen mode Exit fullscreen mode

…finally produces the correct HTML with the default classes applied except for the default "font-medium" class which is replaced by "font-bold". And the label finally gets rendered bold, phew!

The proper HTML is generated now

Miscellaneous goodies

Using a custom builder allows to play with the form building API in unlimited ways. For example, as we’ve said above, we lay out our admin forms in a 12-column grid. To support spanning columns in a convenient way:

= f.input :e_mail, col_span: 4
Enter fullscreen mode Exit fullscreen mode

…we defined a private helper method in the builder that translates this custom col_span option to the Tailwind col-span-X class on the wrapper div.

We added more syntactic sugar to e.g. autofocus the first field of the form or to add some data attributes to connect a Stimulus controller for certain special input types. The options are endless 🙂.

Summary

Using a style-less configuration and a custom form builder, we were able to create a system for styling our Simple Form forms with Tailwind, without giving up any flexibility and without resorting to monkey-patches. This combo lives happily on our production web now. If you try a similar approach, please drop us a note!

If you don’t want to miss future posts like this, follow me here or on Twitter. Cheers!

Discussion (0)