Dom Christie

Tailwind Repetition

The biggest complaint about Tailwind CSS is that it results in long, ugly lists of class names that are difficult to maintain, particularly if they’re repeated.

This popped up again the other day, with the following example… How do you usually avoid this in #tailwind? #rubyonrails:

<%= link_to "Home", "/", class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<%= link_to "Projects", "/projects", class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<%= link_to "Monitor", "/monitor", class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<%= link_to "Dashboard", "/dashboard", class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>

With a traditional CSS approach, it’d be common to create a .nav-link class, and set the styles in a separate stylesheet. With Tailwind, this approach can be achieved with @apply:

<%= link_to "Home", "/", class: "nav-link" %>
<%= link_to "Projects", "/projects", class: "nav-link" %>
<%= link_to "Monitor", "/monitor", class: "nav-link" %>
<%= link_to "Dashboard", "/dashboard", class: "nav-link" %>
/* application.css */
@layer components {
  .nav-link {
    @apply text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500;
  }
}

However, one of the key benefits of just using Tailwind’s classes is you don’t have to worry about naming things, or architecting and maintaining your own CSS files. With @apply, you end up dealing with those considerations.

So, if @apply isn’t recommended, what are the other options?

One of my favourite hidden features of Tailwind is that the ugliness of the class names encourages you to think about how to improve the structure of view code. Here’s an analysis of some of the options mentioned in that thread.

Alternative Approaches

1. Do Nothing

If this is the only place these classes used, it might be preferable to leave it as-is. The styles may only change very rarely (or not at all), and so updating the list of classes isn’t a maintenance burden. Most editors support multi-cursor editing to help with this.

If the styles do change frequently, and updates are beginning to hurt, then consider another approach, but just because it’s repetitive and ugly, it doesn’t necessarily mean it’s hard to maintain. While it might seem unsightly, there’s value in patiently waiting for a good abstraction to present itself; now might not be the right time to shift things around. I’d rather deal with long lists of classes over untangling specificity issues in arbitrarily organised CSS files.

The benefit here is that there’s no misdirection and no naming decisions. All the styles are there to be seen and updated. What’s more, if one of the links requires custom styles, it’s easy to break out of the standard styles for that exception.

2. Local Variable

<% classes = "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<%= link_to "Home", "/", class: classes %>
<%= link_to "Projects", "/projects", class: classes %>
<%= link_to "Monitor", "/monitor", class: classes %>
<%= link_to "Dashboard", "/dashboard", class: classes %>

This is a good first step to avoiding the repetition. It’s clear, and breaking out of the standard styles would be easy. However, there’s still a fair bit of repetition outside of the class names: <%= link_to "…, "…", class: classes %>. If we really felt the need to DRY it up, we could do better.

In fact, it looks like the original example failed to include the data-active attribute, so in reality, an updated version might be:

<% classes = "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<%= link_to "Home", "/", class: classes, data: {active: current_page?("/")} %>
<%= link_to "Projects", "/projects", class: classes, data: {active: current_page?("/projects")} %>
<%= link_to "Monitor", "/monitor", class: classes, data: {active: current_page?("/monitor")} %>
<%= link_to "Dashboard", "/dashboard", class: classes, data: {active: current_page?("/dashboard")} %>

This looks similar to the @apply approach, but it keeps the classes in the same file as the markup. However, we’ve only reduced the repetition of the CSS classes. The current_page? check is still repetitive. Adding another link would require adding link text, a path, and ensuring the active logic is correct.

3. Loop

<% [
  ["Home", "/"],
  ["Projects", "/projects"],
  ["Monitor", "/monitor"],
  ["Dashboard", "/dashboard"]
].each do |text, path| %>
  <%= link_to text, path, class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500", data: {active: current_page?(path)} %>
<% end %>

This really cleans up the repetition. The classes and active attributes are only stated once. Adding links is just a case of modifying the array. Breaking out of the styles could be achieved with another option, e.g.

<% [
  ["Home", "/", "text-black text-bold …"],
  ["Projects", "/projects"],
  ["Monitor", "/monitor"],
  ["Dashboard", "/dashboard"]
].each do |text, path, classes| %>
  <%= link_to text, path, class: classes || "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<% end %>

But the deeper benefit is that the code is beginning to reveal potential improvements for how to model the UI. The collection of values suggests thee presence of an underlying object.

If the navigation requirements were to become more complex, you might consider encapsulating these values in a class and rendering them as follows:

# app/models/navigation_item.rb
class NavigationItem
  attr_reader :path, :text

  def initialize(path, text)
    @path = path
    @text = text
  end

  def self.all
    [
      ["Home", "/"],
      ["Projects", "/projects"],
      ["Monitor", "/monitor"],
      ["Dashboard", "/dashboard"]
    ].map {|text, path| new(path, text) }
  end
end
<% NavigationItem.all.each do |item| %>
  <%= link_to item.text, item.path, class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" %>
<% end %>

Then if the markup becomes complex, move it to a partial. By setting to_partial_path in the class, Rails can automatically lookup and render the partials:

class NavigationItem
  # …
  def to_partial_path
    "navigations/item"
  end
end
<%# app/views/navigations/_item.html.erb %>
<%= link_to item.path, class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500" do %>
  <%# … %>
  <%= item.text %>
<% end %>
<%= render NavigationItem.all %>

The downside is that you’ve had to name the concept. However, by this stage, the UI concept is concrete and the naming decision is clear. The HTML can still be written in html+erb files, and hopefully the styles are established enough to not need frequent updating.

The best feature of this approach, is that there are clear options as the code grows and becomes more complex. We’ve gone from looping over a bit of data, to looping over objects that represent the data, to partials that are automatically rendered.

<%= nav_link_to "Home", "/" %>
<%= nav_link_to "Projects", "/projects" %>
<%= nav_link_to "Monitor", "/monitor" %>
<%= nav_link_to "Dashboard", "/dashboard" %>
module UiHelper
  def nav_link_to(text, path)
    link_to text, path, class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500", data: {active: current_page?(path)}
  end
end

This is a straightforward approach that encapsulates the markup with the variables passed in. It looks tidy, but I’ve found it can be problematic.

First, it looks like Rails’ own link_to, so there might be an expectation that it mirrors that API, but it doesn’t. Of course we could create an identical API that wraps link_to and adds the classes, but that adds complexity. If Rails extends its link_to API, our code should follow suit to avoid confusion.

Second, writing markup in helpers is awkward. Fine for a single element, but too many more and you end up concating strings, or calling html_safe. As such, this approach isn’t really open to extension.

Finally, you have to choose how you organise your helpers. Should this live in ApplicationHelper? Should I create a new helper? UiHelper? NavigationsHelper? You immediately end up with the same naming and architecture problems that Tailwind aims to solve.

5. ActionView::Attributes

<%= link_to "Home", "/", ui.nav_link("/") %>
<%= link_to "Projects", "/projects", ui.nav_link("/projects") %>
<%= link_to "Monitor", "/monitor", ui.nav_link("/monitor") %>
<%= link_to "Dashboard", "/dashboard", ui.nav_link("/dashboard") %>
module UiHelper
  def ui
    @ui ||= Ui.new(self)
  end

  class Ui
    delegate_missing_to :@view_context

    def initialize(view_context)
      @view_context = view_context
    end

    def nav_link(path)
      tag.attributes class: "text-white px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 data-[active=true]:bg-gray-500", data: {active: current_page?(path)}
    end
  end
end

The ActionView::Attributes gem adds enhances Rails’ tag.attributes helper. It makes it easy to build collections of HTML attributes for sharing in various contexts and elements. It’s really smart about how attributes are merged. For example:

tag.attributes(class: "text-bold").merge(class: "text-blue-700")
# outputs: "class='text-bold text-blue-700'", no clobbering!

You can think of this as composition. For example, we can apply a set of button attributes (ui.button) like so:

<%= link_to "Get Started", new_user_path, ui.button %>
<%= button_to "Like", likes_path, ui.button %>
<%= submit_tag "Join", ui.button %>
<%= form.submit "Update", ui.button %>

This is preferable to “button” component as it enables developers to use the familiar Rails APIs they’re used to (link_to, button_to, etc.) while maintaining consistent styling and behaviours.

Components work best when they share both elements and attributes; composing attributes works best when the attributes are shared amongst various elements.

The downside here is that you have to architect your attributes in a separate file. As such, it’s better suited to established APIs, when you’re reasonable confident of the set of attributes and how they fit into the broader UI.

Conclusion

The explicit repetitiveness of Tailwind’s classes pushes developers to look for ways improve their code structure. Combining Tailwind classes into single compound classes (like .nav-link) only reduces HTML class repetition—not repetition in the HTML, and exploring other approaches may reveal better ways to model the user interface.

My first thought to tidy repetitive Tailwind classes was to use ActionView::Attributes. It’s a flexible approach, and very good for sharing attributes amongst different elements. However, when examining the benefits of a loop, it’s probably a better fit for the original problem. Its extensibility helps guide you towards a more structured approach to their UI, and provides options as the code grows.

However, I’d encourage developers to tolerate a level of messiness. Only commit to a lower-repetition approach when it hurts to maintain consistent styles. That way you have a better understanding of the problem and can make a more informed decision as to the best approach.

See also: Adam Wathan’s flowchart for deciding when to extract a component with @apply.