Lesson 3: Braving Mad Science with Tailwind.

Post 3 in the 6 Things We Learned Revamping Ruby Central's Conference Websites series

Jonathan Greenberg
By Jonathan Greenberg
November 15, 2022

This is the third post in a series on things we explored while “Revamping the Ruby Central Conference Websites”.

In our last blog post we described how we created a custom CMS for the static pages of the website conferences within the existing CFPapp application. We left you with a bit of cliffhanger in the demo mentioning that tailwind utility classes were able to be used in these dynamically generated static pages.

Most developers and designers these days are familiar with Tailwind as it has rapidly become one of the most if not the most popular css framework. That alone would be reason enough for us to want page designers to have access to this powerful tool. However, there were additional reasons as well.

Of course, since we have given access to composing the html directly for the pages a designer always would have the option of using inline styles. Indeed, Tailwind is often compared to and somewhat disparaging at times to inline styles. However, while there are similarities, the case can easily be made to how different and superior it is .

For our purposes Tailwind offers a terse and common language for designers to communicate their design intent on the page to both the browser and future designers. Using Tailwind our designers do not need to come up with clever names for css classes that ultimately obscure the actual structure of the design. Moreover, Tailwind comes with sensible and thoughtful defaults for modern, responsive designs and with the power of arbitrary values no flexibility is lost.

Traditionally, Tailwind is configured and ultimately compiled on the backend to purge and minify the css for production. However, we would not be able to take advantage of those features since the html for pages would be created after deployment. Certainly including all of Tailwind would severely bloat our css and was not desirable.

Fortunately, we discovered that we could use the JIT compiler for Tailwind as used by the Play CDN. This provides virtually all of the powerful features of Tailwind on the fly in realtime. Using MutationObserver the Tailwind js quickly analyzes the html on the page and injects just the css that is needed. So rather than purging what is not needed on the backend, we get just the minimal css needed injected on the front end.

However, to make full use of all the powerful features of the Tailwind JIT compiler you need to be able to add script tags to the head. That was easy enough for us to provide and so again using CodeMirror we gave page designers a way to add blocks of code to the head of the layout. We chose to do this at the website level so that all pages would share the same code but this may ultimately need to be added or replaced by doing it at the page level.

-# app/views/staff/websites/_form.html.haml
%div{"data-controller": "nested-form"}
  = legend_with_docs("Head and Footer Content")
  %template{"data-nested-form-target": "template"}
    = f.simple_fields_for :contents,
      child_index: 'NEW_RECORD' do |content|
      = render 'content_fields', form: content
  = f.simple_fields_for :contents do |content|
    = render 'content_fields', form: content
  %div{"data-nested-form-target": "links"}
    = link_to "Add Content", "#", class: 'btn btn-success',
      data: { action: "click->nested-form#add_association" }

 -# app/views/staff/websites/_content_fields.html.haml
= content_tag :div,
  class: 'nested-fields',
  data: { new_record: form.object.new_record? } do
    = form.input :name
    = form.input :placement, collection: Website::Content::PLACEMENTS
  = form.input :html,
    as: :text,
    input_html: { data: { "content-target": :textArea } },
    wrapper_html: { data: { "controller": "content" } }

  = link_to "Remove", "#", data: { action: "click->nested-form#remove_association" }
  = form.hidden_field :_destroy

Note the use of both a nested-form stimulus controller (thanks GoRails!) for handling nested attributes for these website#contents and a content stimulus controller which initializes the CodeMirror editor.

Giving access to adding styles to the head of the page a designer can now configure their tailwind similarly to how a developer would on the backend and just as they would explained in the Tailwind PlayCDN documentation. We can illustrate the power of these tools with the following demonstration:

tailwind demonstration

Note how we can configure our own custom primary and secondary colors using css variables. Moreover, we can even add our own custom javascript function to dynamically set the opacity without custom utility tailwind color classes.

There are some caveats to using the JIT Tailwind compiler in production and it is generally not advised to be used in this way. I asked the authors about this on Discord and got the following explanation:

The biggest reason is because it uses MutationObserver to add the styles, it can’t detect styles for dynamically created elements fast enough to avoid a flash of unstyled content (FOUC). For example, say you have some JavaScript that opens a modal, and the modal is supposed to transition in when it opens. When the modal opens, the HTML for it is inserted into the DOM right away, but the styles might not exist for it yet because you haven’t used those same classes elsewhere in the file. The observer will fire, and Tailwind will generate the styles, but the modal is already open, so you’re going to see an unstyled flash the first time it opens. We recommend pulling in the JIT CDN as a blocking (so not deferred) script to avoid the FOUC for the very initial render, but that of course means it adds 100ms (or whatever) before the page is even rendered. Not a big deal really but using a static CSS file is way faster. It’s also quite large (almost 100kB compressed) whereas compiling your CSS ahead of time usually leads to something closer to 10kb compressed, and with no run time overhead. TLDR; It’s probably fine for simple static pages but it’s really much better to build the static CSS file.

In our use case the flicker will not be an issue since our pages will be quite static once they are composed and published for public consumption. However, it did get me wondering whether it would not be possible to generate the minimal css that the JIT js normally renders in the browser before publishing and just add that to the head instead of the heavier JIT js file. At first I tried a bit of a mad science experiment using a headless Chrome driver with help from the puppeteer-ruby gem which looked something like this:

  def save_tailwind_page_content
    Puppeteer.launch do |browser|
      page = browser.new_page
      cookies.each do |name, value|
        page.set_cookie(name: name, value: value)
      page.goto(event_staff_page_url(current_event, @page), wait_until: 'domcontentloaded')
      css = page.query_selector_all('style').map do |style|
        style.evaluate('(el) => el.textContent')
      end.detect { |text| text.match("tailwindcss") }
      html = "<style>#{css}</style>"

      content = @page.contents.find_or_initialize_by(name: Page::TAILWIND)
      content.update!(placement: Website::Content::HEAD, html: html)

However, I then came to my senses and realized that I could simply use the tailwind cli which uses the same JIT engine underneath the covers. Here is the latest version:

class Staff::PagesController < Staff::ApplicationController

  def save_tailwind_page_content
    @body = @page.unpublished_body
    content = render_to_string(template: 'pages/show', layout: themes/#{current_website.theme}")
    command = ["yarn run tailwindcss --minify"]
     page_file = Tempfile.new(['page_content', '.html'], 'tmp')
     command.push("--content", page_file.path)
     if tailwind_config = current_website.tailwind_config
      config_file = Tempfile.new(['config_file', '.js'], 'tmp')
       command.push("--config", config_file.path)
     output = `#{command.join(' ')}`
     css = output.match(/(\/\*! tailwindcss .*)/m)
     html = "<style>#{css}</style>"
     content = @page.contents.find_or_initialize_by(name: Page::TAILWIND)
    content.update!(placement: Website::Content::HEAD, html: html)

class Website < ApplicationRecord
  def tailwind_config
    config = contents
    if config
      config.html.gsub(%r{<script>|</script>},"").gsub("tailwind.config", "module.exports")

With that approach we get the best of both worlds with dynamically generated Tailwind minimal css saved directly in the page header so that it can be cached with the lightest load possible for the browser.

Speaking of caching, that will be another great exploration for the next blog post in this series.

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.