Galaxy brain CSS tricks with Hotwire and Rails
In Hotwire applications, you need to lean more on the fundamentals of CSS and HTML. If you’re like me, you probably learned just enough CSS to get by, but never reach for it first. But that’s changed recently and I wanted to share patterns I’ve picked up recently that improve my Rails apps.
Empty States and Turbo Streams
An extremely common pattern in Rails apps is rendering a collection of elements and if the collection is empty, render an empty state.
<div id="my_list" class="flex flex-col divide-y">
<% if @list.size > 0 %>
<%= render partial: "list_item", collection: @list %>
<% else %>
<p>
Whoops! you have no items!
</p>
<% end %>
</div>
This works fine when rendering a typical page, but if you use Turbo Streams to add or remove list items, you’ll find a problem.
If you had two items in the list, and you remove both via Turbo Streams, the container will be empty but you won’t have rendered the empty state. And if the list is empty and you dynamically append an item, you’ll want to remove the empty state.
You could re-render the whole list instead of inserting items, but one technique that I’ve found helpful is using the CSS only-child
pseudo-selector.
I’ll show examples with Tailwind (because Tailwind is really, really good), but the same concept applies in regular CSS.
The idea is to always render the empty state and then use CSS to only show it if there are no items.
<div id="my_list" class="flex flex-col divide-y">
<p class="only:block hidden">Whoops! you have no items!</p>
<%= render partial: "list_item", collection: @list %>
</div>
Using Tailwind’s only
modifier we set the empty state to have display block
if it is the only child of the container, otherwise hide it.
Now you can stream back operations to append or remove items to the my_list
container and let CSS handle hiding or showing the loading state.
Note: you may want to use last-child
, first-of-type
, or some other modifier depending on your specific markup. Give it a shot!
Tailwind Variants with Data Attributes
Hotwire, especially Stimulus, makes use of HTML data attributes heavily. One neat trick is using data attributes to reduce conditionals in views.
Let’s say you have a list of comments and only admins can delete the comments.
<%= tag.div id: dom_id(@comment) do %>
<p><%= @comment.body %></p>
<% if Current.user.admin? %>
<%= button_to "Delete", @comment, method: :delete %>
<% end %>
<% end %>
You could instead use a data attribute on the <body>
of your page to conditionally show admin-related things.
<body <%= 'data-admin' if Current.user.admin? %>>
...
</body>
And then write styles based on that attribute. Tailwind makes it easy to add custom variants for this via the plugins
section of the Tailwind config file:
plugins: [
function({addVariant}) {
addVariant('admin', 'body[data-admin] &')
}
],
With this config change you can now use admin:
with any Tailwind classes.
<%= tag.div id: dom_id(@comment) do %>
<p><%= @comment.body %></p>
<div class="admin:block hidden">
<%= button_to "Delete", @comment, method: :delete %>
</div>
<% end %>
But wait? Isn’t this super risky because someone could just fiddle with the HTML and delete a comment? Well, yes, they could – but you need to be checking authorization on the server-side anyways. It’s a trade-off but there are cases where this cleans up a ton of conditional view logic.
Shout-out to my friend Marc Kohlbrugge for sharing the idea on Twitter!
In our app we recently used this technique to change how much margin we needed on an element based on user roles. Certain roles have a fixed height header that we needed to account for. Instead of a bunch of conditionals, all we had to end up writing was viewer:top-0 editor:top-12
.
Dynamic styles with erb
Don’t forget that you can use generate <style>
tags dynamically!
<style>
[data-user~="<%= Current.user.id %>"] {
background: yellow;
}
</style>
<div>
<p><%= @comment.body %></p>
<%= tag.span data: { user: @comment.author.id } do %>
<%= @comment.author.name %>
<% end %>
</div>
You could use a little CSS to highlight comments that you made with a yellow background by targeting a data-user
attribute.
This is actually an old Rails technique from Basecamp that was popular because it works really well with fragment caching. You can cache the same chunk of HTML and then use CSS to change the styles instead of needing multiple, slightly different cache entries.
I’ve also used this concept for building custom theming features by taking advantage of CSS variables.
<style>
:root {
--color-brand: <%= @account.brand_color %>;
--color-brand-contrast: <%= ColorHelper.contrast(@account.brand_color) %>;
--color-brand-tint: <%= ColorHelper.tint(@account.brand_color) %>;
}
</style>
You can use this to define custom Tailwind colors:
module.exports = {
theme: {
extend: {
colors: {
brand: "rgb(var(--color-brand) / <alpha-value>)",
"brand-contrast": "rgb(var(--color-brand-contrast) / <alpha-value>)",
"brand-tint": "var(--color-brand-tint)",
},
},
},
};
And now you can use bg-brand
or text-brand-contrast
in your application.
Stop string interpolating class names!
You will be writing a lot more HTML markup in Hotwire apps: more views, more partials, more components. Make sure you are taking advantage of newer Rails features for generating HTML without doing a bunch of gross string interpolation.
If you’re writing markup like this:
<li class="bg-gray-50 p-2 text-gray-700 <%= 'line-through' if @task.completed? %>">
<%= @task.name %>
</li>
Please stop! It’s hard to read and tricky to match all of the closing punctuation. There are better ways!
<%= tag.li class: ["bg-gray-50 p-2 text-gray-700", "line-through": @task.completed?] do %>
<%= @task.name %>
<% end %>
Rails 6.1 added a class_names
helper method and the tag
builder will automatically use it for conditionally setting class values. It’s awesome!
It’s extra powerful when using a library like ViewComponent where you have a lot of conditional styles, or just want to group up utility classes in a more organized manner.
class MyWidget < ViewComponent::Base
...
def container_classes
[
"flex items-center justify-center space-x-2 rounded-full",
"disabled:pointer-events-none disabled:select-none",
"font-medium tracking-wide",
{"text-white bg-black hover:bg-neutral-900": variant == :primary},
{"text-neutral-600 border hover:bg-neutral-50 hover:text-neutral-900": variant == :secondary},
{"text-neutral-600 hover:text-neutral-900 hover:bg-neutral-50": variant == :tertiary},
{"w-full": full_width?}
]
end
end
Wrap it up
CSS is often one of the last tools Rails developers reach for when trying to solve a tricky problem. We are much more inclined to add conditionals to views or fall back to string interpolation to “make it work”. But there are a few techniques that can make working with CSS in your Rails app improve the readability and durability of your code.
Even though Hotwire’s servered rendered approach feels retro, remember that we don’t have to use CSS like it’s 1998 anymore!
Was this article valuable? Subscribe to the low-volume, high-signal newsletter. No spam. All killer, no filler.