DEV Community

matt swanson
matt swanson

Posted on • Originally published at boringrails.com on

Galaxy brain CSS tricks with Hotwire and Rails

This post is part of Hotwire Summer: a new season of content on Boring 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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] &')
  }
],
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)'
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!


Top comments (0)