Template-Scoped CSS in Rails

Hot on the heels of the previous article I was asked about my idea of having co-located CSS. Now is the time to share, so read on!

First: Co-located styles make sense, period

Let’s get this out of the way first: I do believe that component-based web app development is the right approach, and CSS styles form a part of the API of a component just as its markup and its JS do. If you have a Card in your application, and you use it in more than 1 place, it is absolutely right to want to do this:

<!-- _card.html.erb -->
<style scoped-to-element-below>
  .component {
    display: block;
    min-width: 240px;
    padding: 4px;
    border-radius: 4px;
  }
  .component .title {
    font-size: 12px;
  }
</style>
<div class="component">
  <h2 class="title"><%= some_kind_of_scope.title %></h2>
</div>

Why so? Well, for a whole number of reasons:

So: I am firmly in the camp of “localized” or “scoped” styles. Webpack, back in the day, did offer us (a terrible) solution for this: just like any resource, you could - it’s terrible, but it worked - import a CSS stylesheet as if it was JS. It’s an absolutely horrible idea, really, but it did kind of permit the workflow I cherish here. You would have:

MyComponent.scss
MyComponent.js

and these two would work in harmony. The MyComponent.js would import the MyComponent.css - it would not import .scss because having SASS wired into this mess was another lap dance you had to do and which I don’t want a rerun of.

And then it would sorta-kinda work. And it was nice.

Now, apparently, Vite does have something similar in the shape of “CSS modules” - but, contrary to what one may think, these modules are neither “official” Web tech nor supported natively - it’s just a replication of the same pattern. It may work for you.

But I want something even better.

Co-locating on a template basis

What we are going to do here did exist before - it’s 💅 styled-components - just without any Node or React or Webpack. And without the “💉head injections”. I am actually perplexed why Github folks didn’t arrive at the same setup, but then again - who am I to wonder?.. Note that I don’t take template nesting into account here - but you could, you just need to drive the pickaxe a bit deeper (to the level where Rails computes the cache keys for its russian doll caching, which does honor template nesting).

⚠️ Yes, this is technically not valid HTML, because there should be a lot of arguing about all the things

  • but I care about what works, not about what’s correct. You can, however, turn this into a head injection if you use content_for - but in that case, you will lose the nice property of being able to inject styles inside partials using Hotwire (which is kind of the whole idea).

Another note: this technique may cause FUOCs (flashes of unstyled content) if your CSS is heavy - that is, if it loads assets such as fonts.

In Rails, every template gets cached, and it also gets compiled into an actual method. Template pre-compilation has been present in Rails for a very, very long time - Rails will read your .erb template, convert it into concat method calls with string literals, and then will create a dynamically-defined method on your ActionView template object. That method can then be optimized using JIT. The name of the method is going to change when your template changes. There is our identifier - the name of the method compiled from the template name.

Next, we are going to use a hack which was apparently avoided by styled-components. See, the common approach to style elements is that you can only put them in the <head> element of your page. But browsers are, actually, just fine also using style elements anywhere on the page - and this is exactly what we are going to use.

The second key element we want is CSS selector nesting – it used to be that you needed SASS for this, but now this feature is natively available in most modern browsers.

There is also the scoped attribute on the style element but we won’t need that. What we are going to do is roughly this:

<style>
  .acef1256 {
    display: block;
    min-width: 240px;
    padding: 4px;
    border-radius: 4px;

    .title {
      font-size: 12px;
    }
  }
</style>
<div class="acef1256">
  <h2 class="title"><%= title %></h2>
</div>

That is: we want an automatically-defined scoping class for our template (usually - a partial). We want that class to be output just once, so if we output multiple cards, we want the output to be this:

<style>
  .acef1256 {
    display: block;
    min-width: 240px;
    padding: 4px;
    border-radius: 4px;

    .title {
      font-size: 12px;
    }
  }
</style>
<div class="acef1256" id="<%= dom_id(card1) %>">
  <h2 class="title"><%= card1.title %></h2>
</div>
<div class="acef1256" id="<%= dom_id(card2) %>">
  <h2 class="title"><%= card2.title %></h2>
</div>

The acef1256 is going to be an automatic class name, generated from the path of our current template, along with a checksum of our template file. This will enable the following nice properties:

And that’s it! Not much more to it.

Scoping our styles

It’s really way less complicated than it may seem. The only complicated bit is getting access to the metadata about the current ActionView template we are rendering. Let’s define us a helper:

class ScopedStylesHelper
  def css_class_for_this_template
    raise "@current_template is not set, so we can't intuit the wrapping class name for this block" unless @current_template.present?

    # Rails computes the method name the template will be compiled into.
    # This method name changes with changes to the template
    # source, and includes the digest-like details and all the other useful bits.
    compiled_template_method_name = @current_template.method_name
    # We will use the virtual path as the base for our CSS class
    template_virtual_path = @current_template.virtual_path
    _css_class_name = template_virtual_path.gsub(/(\/|_)/, "-") + "-" + Digest::SHA1.hexdigest(compiled_template_method_name)[0..3]
  end

  def css_class_selector_for_this_template
    ".#{css_class_for_this_template}"
  end
end

The key item here is that we are combining the name of the template file - which could well be something like _card.html.erb - and the name of the precompiled method which caches that template.

The output, then, once we actually use our css_class_for_this_template helper method, will be something like this:

<style>
.projects-show-d263 {
  .gallery {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
  }
}
</style>

<div class="projects-show-d263">...</div>

The d263 is that magic part derived from the checksum of our compiled template method.

Outputting once

Not that hard either. We want there to be only one <style> element output, regardless of how many _card.html.erb partials we render. For that, we want to keep some kind of page-wide cache available:

def once_per_template(html_content = nil)
  if !block_given? && html_content.blank?
    raise "You need to either pass the HTML code as the first argument or render it from the passed block"
  end
  already_output = css_class_for_this_template
  @per_template_outputs ||= Set.new
  return if @per_template_outputs.include?(already_output)

  # https://thepugautomatic.com/2013/06/helpers/
  capture do
    concat((block_given? ? yield : html_content) + "\n")
  end.tap do
    @per_template_outputs << already_output
  end
end

This way, regardless how many times we render a block with once_per_template, it is only getting output once. I find it nicer to use a generic helper method like this, because inside of its block we can put our <style> element - which is going to have proper syntax highlighting in our editor!

Putting it all together

Having set that up, we can define our style:

<%= once_per_template do %>
  <style>
    <%= css_class_selector_for_this_template %> {
      .title {
        color: red;
      }
    }
  </style>
<% end >

and our wrapper element:

<div class="<%= css_class_for_this_template %>">
  <h2 class="title"><%= title %></h2>
</div>

And that’s it! Localized styles for your Rails templates and partials - with zero post-processing.

Shouldn’t that be a gem?

I don’t know if it’s worth it for the 50-something lines of code it consistes of. Maybe? I already got almost a hundred gems to my name that nobody is using, and having one more - with CI and other things - might be excessive. If you think this would make a nice gem - let me know.