Skip to content

    Let‘s talk about how to render CSS dynamically with Ruby on Rails.

    Most of the time, you might think of CSS as a static asset. But sometimes you want dynamic CSS. Maybe end-user preferences, results of an A/B test, or some organizational data in the system should determine what styles to present.

    In this post, we‘ll talk about some techniques and considerations to accomplish this in Rails. In my previous article, I used Hotwire to let you, the reader, preview and save different color schemes for this site. As a recap, here‘s a slim demo so you can see how it works:

    (You can visit the settings page to save your preferred color scheme to your session.)

    Link to heading ERB isn‘t just for HTML

    The color scheme preview relies on Ruby embedded in HTML templates on the server to render CSS into a <style> tag.

    While most of your CSS likely should be rendered in a bundled CSS file (or files), like application.css, it may make sense for small bits of custom CSS to be rendered inline in HTML. Your static CSS files will be served from your web server or likely a Content Delivery Network (CDN) depending on your application setup, which skips your Rails application logic.

    That‘s true for most of the CSS in Joy of Rails too. But, to make the the color scheme preview, I‘ve mixed in some dynamic CSS into the Hotwire interaction. When you make a color scheme selection, a request is issued to update the color scheme preview. The endpoint returns an HTML response with a Turbo Frame containing a <style> tag. Hotwire swaps out the portion containing the Turbo Frame including the new styles. As an example, here‘s part of the HTML response when you select Blue Chill:

    html
    <html>
      <head></head>
      <body>
        <!-- ... -->
        <section class="...">
          <turbo-frame id="color-scheme-preview">
            <style>
              :root {
                --color-custom-blue-chill-50: hsla(180, 53%, 97%, 1);
                --color-custom-blue-chill-100: hsla(178, 64%, 89%, 1);
                --color-custom-blue-chill-200: hsla(179, 64%, 78%, 1);
                --color-custom-blue-chill-300: hsla(182, 58%, 64%, 1);
                --color-custom-blue-chill-400: hsla(183, 49%, 50%, 1);
                --color-custom-blue-chill-500: hsla(184, 61%, 37%, 1);
                --color-custom-blue-chill-600: hsla(185, 64%, 32%, 1);
                --color-custom-blue-chill-700: hsla(186, 59%, 26%, 1);
                --color-custom-blue-chill-800: hsla(187, 53%, 22%, 1);
                --color-custom-blue-chill-900: hsla(187, 46%, 19%, 1);
                --color-custom-blue-chill-950: hsla(189, 65%, 10%, 1);
              }
    
              :root {
                --my-color-50: var(--color-custom-blue-chill-50);
                --my-color-100: var(--color-custom-blue-chill-100);
                --my-color-200: var(--color-custom-blue-chill-200);
                --my-color-300: var(--color-custom-blue-chill-300);
                --my-color-400: var(--color-custom-blue-chill-400);
                --my-color-500: var(--color-custom-blue-chill-500);
                --my-color-600: var(--color-custom-blue-chill-600);
                --my-color-700: var(--color-custom-blue-chill-700);
                --my-color-800: var(--color-custom-blue-chill-800);
                --my-color-900: var(--color-custom-blue-chill-900);
                --my-color-950: var(--color-custom-blue-chill-950);
              }
            </style>
            <!-- ... -->
          </turbo-frame>
        </section>
      </body>
    </html>
    

    As you can see, the color scheme for Joy of Rails is built on CSS Variables. Using CSS variables for dynamic CSS isn‘t required but sure makes the job of updating page styles easier. We can set values of CSS variables to new values by rendering new CSS or by JavaScript manipulation; this makes CSS variables an ideal choice for dynamic CSS techniques like those described in this article.

    The partial that renders CSS in the HTML response looks something like this:

    <!--
      You can customize your color scheme for Joy of Rails!
      Visit <%= settings_color_scheme_path %> to preview colors and save your preference.
    -->
    <% if custom_color_scheme? %>
      <style>
        <%= render ColorSchemes::Css.new(color_scheme: find_color_scheme, my_theme_enabled: true) %>
      </style>
    <% end %>

    The controller helper method custom_color_scheme? looks for the presence of a color scheme id in params or the session and find_color_scheme makes a database query to find a ColorScheme record if needed.

    ColorSchemes::Css is a simple Phlex component. Here‘s what it looks like:

    # frozen_string_literal: true
    
    class ColorSchemes::Css < Phlex::HTML
      attr_reader :color_scheme
    
      def initialize(color_scheme:, my_theme_enabled: false)
        @color_scheme = color_scheme
        @my_theme = my_theme_enabled
      end
    
      def view_template
        raw safe(css_variables)
        if my_theme?
          raw safe(my_css_variables)
        end
      end
    
      def css_variables
        css = weights.map { |weight, color| "--color-#{color_name}-#{weight}: #{to_hsla(color)};" }.join("\n\s\s")
        <<~CSS
          :root {
            #{css}
          }
        CSS
      end
    
      def my_css_variables
        css = weights.map { |weight, color| "--my-color-#{weight}: var(--color-#{color_name}-#{weight});" }.join("\n\s\s")
        <<~CSS
          :root {
            #{css}
          }
        CSS
      end
    
      private
    
      delegate :weights, to: :color_scheme
    
      def color_name = color_scheme.name.parameterize
    
      def my_theme? = @my_theme
    
      def to_hsla(color)
        hsl = color.hsl
        "hsla(#{hsl[:h]}, #{hsl[:s]}%, #{hsl[:l]}%, 1)"
      end
    end

    Phlex is a Ruby gem for building object-oriented HTML components. It‘s an alternative to ERB templates. Phlex suits my preferences in this case, but it isn‘t a requirement for dynamic CSS. You could easily write this in an ERB template instead. It might look something like this:

    erb
    <%
    color_name = @color_scheme.name.parameterize
    # #to_hsla defined as a helper method
    %>
    <style>
      :root {
      <%= @color_scheme.weights.map { |weight, color| "--color-#{color_name}-#{weight}: #{to_hsla(color)};" }.join("\n\s\s") %>
    
      <% if @my_theme %>
        <%= @color_scheme.weights.map { |weight, color| "--my-color-#{weight}: var(--color-#{color_name}-#{weight});" }.join("\n\s\s") %>
      <% end %>
    }
    </style>
    

    The key point here is that we can use logic in Rails templates or components for rendering key bits of dynamic CSS at the time of the request, just as you can for HTML. When combined with Hotwire, this enables server-driven interactive styles like my color scheme preview.

    Link to heading Controller actions aren’t just for HTML either

    Rails controllers can do more than just HTML. In fact, Rails controller actions support over thirty MIME types by default (as of Rails 7.1).

    ruby
    $ bin/rails s
    
    irb> Mime::SET.collect(&:to_s)
    =>
    ["text/html", "text/plain", "text/javascript", "text/css", "text/calendar", "text/csv", "text/vcard", "text/vtt", "image/png", "image/jpeg", "image/gif", "image/bmp", "image/tiff", "image/svg+xml", "image/webp", "video/mpeg", "audio/mpeg", "audio/ogg", "audio/aac", "video/webm", "video/mp4", "font/otf", "font/ttf", "font/woff", "font/woff2", "application/xml", "application/rss+xml", "application/atom+xml", "application/x-yaml", "multipart/form-data", "application/x-www-form-urlencoded", "application/json", "application/pdf", "application/zip", "application/gzip", "text/vnd.turbo-stream.html"]
    

    In Rails mime_types.rb, we can see the registered MIME types with each of their recognized extensions. Here‘s the original mime_types.rb, added to Rails on Dec 2, 2006 (source).

    Mime::Type.register "*/*", :all
    Mime::Type.register "text/plain", :text
    Mime::Type.register "text/html", :html, %w( application/xhtml+xml ), %w( xhtml )
    Mime::Type.register "text/javascript", :js, %w( application/javascript application/x-javascript )
    Mime::Type.register "text/calendar", :ics
    Mime::Type.register "text/csv", :csv
    Mime::Type.register "application/xml", :xml, %w( text/xml application/x-xml )
    Mime::Type.register "application/rss+xml", :rss
    Mime::Type.register "application/atom+xml", :atom
    

    Support for CSS was added a short time later on Feb 17, 2007 (commit).

    + Mime::Type.register "text/css", :css
    

    We can take advantage of these registered MIME types to instruct Rails controller behavior with respond_to.

    Using respond_to in a controller allows you to define responses and logic based on the requested format. Here‘s how I take advantage of this behavior in ColorSchemesController#show:

    class ColorSchemesController < ApplicationController
      def show
        @color_scheme = ColorScheme.find(params[:id])
    
        respond_to do |format|
          format.html { render ColorSchemes::ShowView.new(color_scheme: @color_scheme) }
          format.css { render ColorSchemes::Css.new(color_scheme: @color_scheme), layout: false }
        end
      end
    end

    This controller action will respond differently for HTML and CSS requests. Note the format.html and format.css blocks describe alternate results. To demonstrate the difference, I've inserted two iframes for ColorSchemesController#show below:

    Link to heading Iframe for HTML request

    Below you‘ll see an iframe with src set to request the Blue Chill color scheme as text/html.

    Here’s the code for the iframe:

    <%= tag.iframe src: color_scheme_path(@color_scheme_blue_chill) %>
    

    Link to heading Iframe for CSS request

    And here you‘ll see an iframe with src set to request the Blue Chill color scheme as text/css.

    Here’s the code for the iframe:

    <%= tag.iframe src: color_scheme_path(@color_scheme_blue_chill, format: :css), class: "expand" %>
    

    Behold: dynamic css from a controller action!

    We can use this endpoint in a stylesheet <link> tag just like any other static CSS URL:

    html
    <%= stylesheet_link_tag color_scheme_path(@color_scheme, format: :css) %>
    

    One downside of moving our dynamic CSS to a separate controller action is that it requires an additional HTTP request. This could be an issue for a highly interactive user experience. But on the other hand, using a separate request allows you to take advantage of Rails conditional GET features

    Conditional GETs are a feature of the HTTP specification that provide a way for web servers to tell browsers that the response to a GET request hasn't changed since the last request and can be safely pulled from the browser cache.

    Source: Rails guides

    Below, I‘ve modified the ColorSchemesController#show to use the stale? method to enable conditional GET:

    class ColorSchemesController < ApplicationController
      def show
        @color_scheme = ColorScheme.find(params[:id])
    
        if stale?(@color_scheme, public: true)
          respond_to do |format|
            format.html { render ColorSchemes::ShowView.new(color_scheme: @color_scheme) }
            format.css { render ColorSchemes::Css.new(color_scheme: @color_scheme), layout: false }
          end
        end
      end
    end

    This method will calculate a value for Etag or Last-Modified response headers and set the status to 304 Not Modified if request headers match and the server doesn’t need to render anything.

    In short, dynamic CSS combined with a conditional GET allows you to leverage put Ruby logic behind your stylesheet link tags in a performant manner.

    Link to heading Which approach is right for you?

    There‘s no one right answer here but consider the tradeoffs. Both work well with Hotwire; just like any other element, our style tags and stylesheet link tags can be nested inside a Turbo Frame or can be manipulated with a Turbo Stream if we need some interactivity. With a <style> tag, no extra requests to your Rails endpoints are needed to render the page. Separating dynamic css into a controller action can help address bandwidth as response body length as your dynamic styles grow more numerous.

    My thought process is usually:

    • start with dynamic css straight into HTML within a <style>
    • move to a controller action when more flexibility is needed or when HTTP caching features are desired

    Link to heading Recap

    Rails provides us with all sorts of sharp knives especially it comes to dynamic rendering in various formats, like CSS.

    We can allow for real-time style changes based on user preferences or application state to enable user- or context-specific styling—like custom color schemes for your application.

    We can render CSS in <style> tags using embedded Ruby. This approach works well for small bits of CSS and may save bandwidth and latency without having to make additional HTTP requests

    Rendering CSS in a controller isn‘t as crazy as it sounds. This approach offers flexibility and is primed for HTTP caching through conditional GET support in Rails.


    Did you find this article helpful? How are you doing dynamic CSS? Let me know on Twitter, Mastodon, or send me an email. You can check out the source code for Joy of Rails on Github. And... you can subscribe to my newsletter to get notified of new content.

    Until next time, have fun!

    #ruby-on-rails #hotwire #dynamic-css #phlex

    More articles to enjoy