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
andview_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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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_merge
gem 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!