Component variants with the "class_variants" gem

By Exequiel Rozas

- January 21, 2025

If you've been around for long enough, you've probably seen every other way to organize CSS: from writing it on the go to the emergence of frameworks like Bootstrap or the use of organizational methodologies to improve maintainability.

Every one of them had their pros and cons, but the advent and rise to popularity of Tailwind proved that utility-first CSS is generally more convenient for the average developer.

But, the number of classes needed to style a component is generally large, so rendering them conditionally makes reading and understanding what's happening a bit difficult.

We will solve that issue with the class_variants gem to clean up our views and improve the quality of our code.

Let's start by describing the problem:

The issue

Utility-first frameworks like Tailwind are remarkable, but they require us to define numerous classes to achieve our styling goals.

That's even more problematic if we want to introduce versatility into our components: now we have to introduce helpers, decorators or pollute our views with conditionals.

Let's exemplify this with a simple button component that can be of type info or error.

We can achieve the functionality using helpers:

# app/helpers/ui_helper.rb
module UiHelper
  def button_classes(color)
    base_classes = ["flex", "items-center", "justify-center", "px-3", "py-1", "cursor-pointer"]
    case color.to_s
    when "red"
      base_classes.concat(["bg-red-500", "hover:bg-red-400", "text-white"])
    when "blue"
      base_classes.concat(["bg-blue-500", "hover:bg-blue-400", "text-white"])
    end
    base_classes.join(" ")
  end
end

And then, in our views:

<%= button_tag "Start now", type: :button, class: button_classes(:blue) %>

This is a valid way of achieving the feature, but as soon as we start adding more options, like size or roundness, things can get out of control pretty quickly.

The class_variants gem solves this because it allows us to define base and variant styles in a single place using a concise and readable API.

A quick overview

The class_variants gem is a Ruby port of the variant-classnames library of the JavaScript ecosystem.

It allows us to build components with variants using Tailwind classes —or any CSS classes for that matter— without the need to introduce extra complexity into our views or view helpers in the form of conditionals or switch statements.

It's based around the concept of a variant, which is a version of a component with a differing attribute. Say, for example: its color or size.

This makes it specially useful to create design systems or UI components in general because styling depends on the properties of the component and many variants are generally needed to satisfy requirements.

With it, we can:

  • Define base styles that are common to every variant.
  • Create variants around the attributes that make them unique: size, color, width, etc.
  • Create compound variants with more than one attribute.
  • Define default values to render when attributes are missing
  • Define styles for slots that belong to a given variant. For example, the title and body of an alert component that can be of type “alert” or “warning”. ## What we will build To demonstrate how the gem works, we will build a simple design system consisting of buttons and alerts using the class_variants and view_component gems.

For the buttons, the variants will change depending on the color, size, roundness, and we will also have a variant to render outlined buttons.

For the alert component, we will be able to decide the color and the presence, or not, of an icon.

As the alert component can be divided into 4 subcomponents: icon, title, text and close icon, we will be using the gem's slot feature for it.

The final result will look something like this:

Building our UI kit

Besides the class_variants gem, we will be using ViewComponent to build our design system to have the component's logic, presentation, and markup within ViewComponent instances.

So, before anything, we need to install the class_variants and view_component gems:

# Gemfile
gem "class_variants"
gem "view_component"

Then, after successfully running bundle install we should have everything we need to continue.

So let's start by building our button component:

The button component

We will start by creating a ViewComponent that receives color as parameter:

$ bin/rails generate component Button color

This creates 3 files: components/button_component.rb which contains the component's logic, test/button_component_test.rb where we can test the behavior of the component and views/button_component.html.erb which contains the markup that gets rendered.

We will start by writing a test to check that we render the base component correctly when passing the text argument only.

The base classes for our button are: inline-flex items-center rounded border border-transparent font-medium text-white hover:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-300 and we can define them using the ClassVariant.build method.

Let's add those classes to the component:

# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
  def initialize(color:)
    @color = color
  end

  def css_classes
    ClassVariants.build(
      base: "inline-flex items-center rounded border border-transparent font-medium text-white hover:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-300"
    ).render
  end
end

Now, we add the classes to the button element:

# app/components/button_component.html.erb
<button class="<%= css_classes %>">
  <%= content %>
</button>

Next, we can render the component in a view:

<%= render ButtonComponent do %>
  Sign in
<% end %>

We should see something like this:

Base button using the class_variants gem

Even though it's barely visible, the base button is rendering with the appropriate classes.

So now, we will add the color variant to generate the primary color. Note that we will define the color at render time using the color parameter:

# app/components/button_component.rb
class ButtonComponent < ViewComponent::Base
  def initialize(color: :primary)
    @color = color
  end

  def css_classes
    ClassVariants.build(
      base: "inline-flex items-center rounded border-2 border-transparent font-medium text-sm text-white hover:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-300 p-1",
      variants: {
        color: {
          primary: "bg-grape-500 hover:bg-grape-600 text-white dark:bg-grape-400 dark:hover:bg-grape-500",
        }
      }
    ).render(color: @color)
  end
end

Note that we're passing the color: @color argument to the render method. As long as the color is defined in the color hash, the appropriate classes will be returned.

I'm using a custom grape color defined in tailwind.config.js, feel free to use Tailwind's standard colors or define your own custom colors.

Moving on, let's render our first useful button:

<%= render ButtonComponent.new(color: :primary) %>

We should get something like this:

Button using the color variant

As you can see, the appearance is not there yet because we need to work on the specific classes for every button size, but let's add the rest of the colors and render them in line:

# app/components/button_component.rb
{
  variants: {
    color: {
        color: {
          primary: "bg-grape-500 hover:bg-grape-600 text-white dark:bg-grape-400 dark:hover:bg-grape-500",
          secondary: "bg-rose-400 hover:bg-rose-500 border-rose-400 hover:border-rose-400 text-white focus:ring-rose-300",
          dark: "bg-grape-950 hover:bg-grape-900 border-grape-950 hover:border-grape-900 text-white dark:bg-grape-700 dark:hover:bg-grape-800 dark:border-grape-700 dark:hover:border-grape-800 dark:text-white",
          light: "bg-grape-100 hover:bg-grape-300 text-grape-900 border-grape-100 hover:border-grape-300 dark:bg-grape-200 dark:hover:bg-grape-300 dark:hover:text-grape-900 dark:border-grape-200 dark:hover:border-grape-300",
          secondary_light: "bg-rose-100 hover:bg-rose-200 text-rose-900 border-rose-100 hover:border-rose-200 hover:text-rose-950 dark:bg-rose-200 dark:hover:bg-rose-300 dark:text-rose-950 dark:border-rose-200 dark:hover:border-rose-300 dark:hover:text-rose-950",
        },
    }
  }
}
<div class="mt-4 flex items-center space-x-3">
  <%= render ButtonComponent.new(color: :primary) do %>
    Primary
  <% end %>
  <%= render ButtonComponent.new(color: :secondary) do %>
    Secondary
  <% end %>
  <%= render ButtonComponent.new(color: :dark) do %>
    Dark
  <% end %>
  <%= render ButtonComponent.new(color: :light) do %>
    Light
  <% end %>
  <%= render ButtonComponent.new(color: :secondary_light) do %>
    Secondary Light
  <% end %>
</div>

And we get this:

Every variation of the color attribute

Now, we will introduce a size variant to control that attribute too. We just need to add a size key to the variants hash and have it define the values for the different sizes:

# app/components/button_component.rb
size: {
  small: "px-2 py-1",
  medium: "px-3 py-1.5",
  large: "px-6 py-2 text-base",
}

As you can see, we're just changing the padding and adding a larger text for the large button.

Now, we can render medium and large buttons, passing the size attribute to the component. By the way, don't forget to add it to the initializer; otherwise you'll get an error.

<%= render ButtonComponent.new(color: :primary, size: :medium) do %>
  Secondary Light
<% end %>

<%= render ButtonComponent.new(color: :primary, size: :large) do %>
  Secondary Light
<% end %>

After rendering one button for every color and size, we get:

Buttons of all sizes and colors

Now we can introduce the rounded variant:

{
  variants: {
    rounded: {
      small: "rounded-sm",
      medium: "rounded-md",
      full: "rounded-full",
    },
  }
}

Again, don't forget to add the rounded attribute to the initializer and to the ClassVariant.build().render method.

Now, after rendering the button variations, we get:

Buttons of all sizes and roundness

As you can see, we're almost there: we're just missing the outlined button.

To achieve it, we will use a compound variant, which is a variant that's composed of more than one attribute.

In our case, we want to combine the color and border attribute. But, the border parameter should be a boolean and not a value.

Luckily, the class_variants gem allows us to build this by passing a string instead of an object to the border variant to define the classes we want to apply whenever the boolean is set to true.

Then, we define the compound_variants properties by defining the classes we wish to render whenever color is set to :primary and border is true:

{
  variants: {
    border: "bg-transparent"
  },
  compound_variants:  [
    { color: :primary, border: true, class: "border-grape-500 text-grape-500 hover:bg-grape-400 hover:text-white dark:border-grape-300 dark:text-grape-300 dark:hover:text-white" },
  ]
}

If you've been paying attention, we added a border-2 border-transparent property to the base attributes so the regular buttons and the outlined ones have the same height.

So, for the primary color outlined button compound variant, we specify the border and text color plus the hover effects and dark mode styles.

Once again, don't forget to add the border parameter to the initializer and render function.

Now, we can render our outlined buttons using the border: true parameter, and we should get the following:

Complete button set including outlined buttons

The alert component

Now that we're done with the button component, let's work on the alert component.

This component presents a bit of a challenge because besides the container, it's composed of 4 parts:

Anatomy of an alert component

You might be thinking that we could define a method for each one of the parts: title_classes, body_classes, etc. and then duplicate the variant attributes we might wish to change.

This could work, but we can avoid the unnecessary duplication using the slot feature of the gem.

With that feature, we can define variant level attributes for each one of the slots of the component.

Let's start by creating an alert component:

$ bin/rails generate component Alert type title body

Now, let's define the classes for an alert of type warning. Instead of passing a hash to the ClassVariants.build method, we pass a block to it where we can call the base, variant and defaults methods and pass them a block where we call the slot method and define the specific classes there:

class AlertComponent < ViewComponent::Base
  def initialize(type:, title: "", body: "")
    @type = type
    @title = title
    @body = body
  end

  def css_classes
    ClassVariants.build do
      base do
        slot :container, class: "relative flex items-center rounded-md border border-zinc-200 bg-zinc-50 py-4 pl-4 pr-6 md:pr-8 space-x-3",
        slot :icon, class: "w-5 h-5"
        slot :title, class: "text-sm font-medium text-gray-800"
        slot :body, class: "text-gray-500"
        slot :close_icon, class: "w-2 h-2"
      end

      variant type: :warning do
        slot :container, class: "border-yellow-200 bg-yellow-100 dark:bg-yellow-200"
        slot :icon, class: "fill-yellow-700"
        slot :title, class: "text-yellow-900"
        slot :body, class: "text-yellow-800"
        slot :close_icon, class: "fill-yellow-700"
      end
    end
  end
end

The corresponding HTML in alert_component.html.erb:

<aside class="<%= css_classes.render(:container, type: @type) %>">
  <div class="flex flex-col">
    <span class="<%= css_classes.render(:title, type: @type) %>"><%= @title %></span>
    <p class="<%= css_classes.render(:body, type: @type) %>"><%= @body %></p>
  </div>

  <div class="absolute top-2.5 right-3">
    <button class="<%= css_classes.render(:close_icon, type: @type) %>">
      <%= inline_svg_tag "icons/close.svg", class: css_classes.render(:close_icon, type: @type) %>
    </button>
  </div>
</aside>

Then, we render the alert in the view:

<%= render AlertComponent.new(title: :warning, title: "Your order might be delayed", text: "We're experiencing high demand and shipping times may be longer than usual. We apologize for any inconvenience this may cause.") %>

And we get:

Alert component with slots

Note that we have to pass the type to each one of the slots, unlike the previous examples where we passed it to the render method for the whole component.

We're making progress, now let's add the other variants:

variant type: :success do
  slot :container, class: "border-emerald-200 bg-emerald-100 dark:bg-emerald-200"
  slot :icon, class: "fill-emerald-600"
  slot :title, class: "text-emerald-900"
  slot :text, class: "text-emerald-800"
  slot :close_icon, class: "fill-emerald-600"
end

variant type: :info do
  slot :container, class: "border-blue-200 bg-blue-100 dark:bg-blue-200"
  slot :icon, class: "fill-blue-600"
  slot :title, class: "text-blue-900"
  slot :text, class: "text-blue-800"
  slot :close_icon, class: "fill-blue-600"
end

variant type: :error do
  slot :container, class: "border-red-200 bg-red-100 dark:bg-red-200"
  slot :icon, class: "fill-red-500"
  slot :title, class: "text-red-900"
  slot :text, class: "text-red-800"
  slot :close_icon, class: "fill-red-500"
end

After rendering them in the view, we get:

Every type of alert without main icon

Finally, to add the main icon, we will add a boolean icon parameter to the component initializer and then render the icon conditionally in alert_component.html.erb:

<% if @icon %>
  <div class="<%= css_classes.render(:icon, type: @type) %>">
    <%= inline_svg_tag icon_path, class: css_classes.render(:icon, type: @type) %>
  </div>    
<% end %>

Note that we also have to define the icon_path method in our component:

def icon_path
  "icons/#{@type}.svg"
end

This means that we have to have icons for every type of alert: warning.svg, error.svg, etc.

We could also use an icon library or even have a dedicated IconComponent in charge of rendering our icons, but in our case is probably not worth it.

Now, after passing the icon: true to the components in the view, we get:

Alerts with icons

Going further

We could continue creating more components for our UI system, but the process would be repetitive, at least from the class_variants gem perspective.

But, if you're planning to start a new project, or you want to introduce some fresh air to an existing project, you should definitely try out the gem using global helpers or ViewComponent if you plan to have many components eventually.

Starting out from a static design is always a nice way to plan your components ahead, but that's not mandatory: you can design in the browser or use existing UI components and port them to your application.

Class merging strategy

By default, the class_variants gem merges the classes for the components using the Ruby concat method, which merges two arrays together.

This works fine, but, if you want to avoid duplication and potential Tailwind class overlap, you can use the tailwind_mergegem which takes care of those issues for us.

To make that work, we should create an initializer and add the following:

# config/initializers/class_variants.rb
ClassVariants.configure do |config|
  config.process_classes_with do |classes|
    TailwindMerge::Merger.new.merge(classes)
  end
end

This means that if we introduce class duplication, the last class will take precedence and only that will be rendered:

# Only "bg-red-500" gets returned
TailwindMerge::Merger.new.merge ["bg-blue-500", "bg-red-500"]

TL;DR

The class_variants gem allows us to have components with variants that depend on certain attributes like color or size.

It allows us to configure the classes for a given component using the ClassVariants.build method that can receive a hash or a block, depending on the level of granularity we want to achieve.

We can define base classes that are shared among every variant.

To define a variant, we simply pass the attribute— size, for example — and it's corresponding values: small, medium, large:

{
  variants: {
    size: {
      small: "px-1 py-0.5",
      medium: "px-2 py-1",
      large: "px-4 py-2"
    }
  }
}

A default variant combination— like color: :blue, size: :medium— can be set to avoid having to explicitly pass those parameters every time we need to render our default component.

Compound variants let us modify a variant when adding a certain value, like border: true together with color: red, for example. To define one, we declare the compound_variants key as the root hash and pass an array of hashes to it with the properties:

{
  variants: {},
  compound_variants:  [
    { color: :primary, border: true, class: "border-grape-500 text-grape-500 hover:bg-grape-400 hover:text-white dark:border-grape-300 dark:text-grape-300 dark:hover:text-white" },
  ]
}

Lastly, the slot feature of the gem allows us to define variants and their corresponding styles to specific sections, or slots, of a component.

To use this feature, we pass a block to the ClassVariants.build method and define the variants and slots inside of it:

ClassVariants.build do
  base do
    slot :container, class: "relative flex items-center rounded-md border border-zinc-200 bg-zinc-50 py-4 pl-4 pr-6 md:pr-8 space-x-3",
    slot :icon, class: "w-5 h-5"
    slot :title, class: "text-sm font-medium text-gray-800"
    slot :body, class: "text-gray-500"
    slot :close_icon, class: "w-2 h-2"
  end

  variant type: :success do
    slot :container, class: "border-emerald-200 bg-emerald-100 dark:bg-emerald-200"
    slot :icon, class: "fill-emerald-600"
    slot :title, class: "text-emerald-900"
    slot :text, class: "text-emerald-800"
    slot :close_icon, class: "fill-emerald-600"
  end
end

The gem comes with a couple more goodies that can help you in your front-end journey, make sure to ClassVariants gem repository to learn more about it.

I hope this article helped you understand the gem, it's usefulness and how to use it to build better components with Rails.

Have good one and happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.