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>
<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:
<%
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).
$ 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:
<%= 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.
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!