10 min read

How to Build AI-Generated Loading Messages in Rails 8 with Hotwire & Stimulus

How to Build AI-Generated Loading Messages in Rails 8 with Hotwire & Stimulus

Have you noticed how Claude Code shows funny loading messages while it’s working? Things like “Wizarding” or “Pontificating.”

I think they’re fun. They give you a small taste of what user experiences with AI could look like in the future.

In this tutorial, we’ll build our own version of that in Ruby on Rails.

The app will generate dynamic loading messages with a little help from AI. Here’s a quick preview:

final result

Here’s the flow:

  • When a user submits a form, we’ll start two background jobs. One will simulate a slow process, and the other will create a list of AI-powered loading phrases.
  • While those jobs are running, we’ll show a few hard-coded loading messages.
  • As soon as the AI phrases are ready, we’ll stream them to the browser, swap them in, and shuffle them with JavaScript.
  • Finally, once the slow process is done, we’ll show a “success” message.

Before we start: What are we using?

For this tutorial, we’ll be using:

  • Ruby on Rails 8 with Solid Queue and Solid Cable — to run background jobs and stream updates to the browser in real time.
I won’t cover how to set up Solid Queue and Solid Cable here. I’ll link a separate guide soon on setting up the full Rails 8 Solid Trifecta (Solid Cache, Solid Queue, Solid Cable).
  • StimulusJS — to shuffle the loading phrases every 2 seconds.
  • ruby_llm and ruby_llm-schema gems — to easily interact with any AI model provider (OpenAI, Anthropic, Gemini, etc.) and enforce structured outputs

That’s it! Let’s dive in.

Step 1. Create new Rails app

rails new phrase_cycler \
--database=sqlite3 \ # simple, zero-config DB for a demo
--css=tailwind \ # easy styling
--javascript=importmap\ # no bundler needed
--stimulus \ # to sprinkle some javascript in our views
--skip-test # keeps the demo lean, we won't get into testing

cd phrase_cycler

Awesome, we have created our Rails "Phrase Cycler" application.

Step 2. Set up a resource controller

We'll make a simple (and dummy) ResourcesController to trigger our background jobs when the user submits a message.

But a "resource" could be anything, for example, instead of ResourcesController you might want a PostsController, a ChatsController or whatever your resource is.

Let's go ahead and run the following generator in your terminal:

rails g controller resources index create

This will:

  • Create ResourcesController#index and ResourcesController#create actions
  • Add the matching view files
  • Add routes to routes.rb

But we don’t actually want the default routes it gives us. In routes.rb, Rails will add these:

get "resources/index"
get "resources/create"

Let’s remove them and replace with this:

resources :resources, only: [ :index, :create ]
root "resources#index"

What this does:

  • Creates a POST /resources route (for form submits)
  • Creates a GET /resources route (for rendering the page)
  • Sets the root path / to load ResourcesController#index

To double check, run:

rails routes -g resources

And you should see:

resources GET  /resources(.:format) resources#index
          POST /resources(.:format) resources#create
     root GET  /                    resources#index

Great. Our routes are set.

3. Build the user interface

We’ll use TailwindCSS v4 and DaisyUI so things look clean and consistent without much effort.

To add DaisyUI to our Rails app, run this in the root directory:

curl -sLo app/assets/tailwind/daisyui.js https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js

This pulls the latest DaisyUI into a single JS file.

Now open application.css and add this:

@import "tailwindcss";
@source "../../../public/*.html";
@source "../../../app/helpers/**/*.rb";
@source "../../../app/javascript/**/*.js";
@source "../../../app/views/**/*";

@plugin "./daisyui.js" {
  themes: lofi --default
}

That’s it. Tailwind and DaisyUI are ready to go.

3.1 Create the UI
Our UI is very simple: a form with one text box and a button. When the user submits, we’ll show the loading phrases like “Loading…”, “Thinking…”, or “Crunching…”.

Later we’ll wire it up with Hotwire, JavaScript, and a few partials. For now, add this to app/views/resources/index.html.erb:

<div class="w-full">
  <div class="flex flex-col items-center justify-center gap-4">
    <%# Loader %>
    <div id="resource-status-container"></div>
    <%# Form %>
    <div class="w-full flex justify-center">
      <%= form_with url: resources_path, method: :post, class: "flex flex-col gap-4 min-w-96", data: { turbo: true } do |form| %>
        <%= form.text_field :message, placeholder: "Send a message", class: "input w-full" %>
        <%= form.submit "Submit", class: "btn btn-primary w-full" %>
      <% end %>
    </div>
  </div>
</div>

At this point, if you run the server and open http://localhost:3000, you should see a clean form on the homepage ready to send messages.

4. Hard-coded loading phrases

Before we bring AI into the mix, let’s zoom out and ask: what are we really trying to do here?

A user submits a message and while waiting for a slow process to finish, we shuffle through fun loading phrases.

Do we need AI for that? Nope. We can just hard code a few phrases and cycle through them with Stimulus. Quick win.

So let’s start simple: submit the form, show phrases, shuffle them on screen.

4.1 Update the controller
In ResourcesController#create, drop this in:

def create
  # TODO: Enqueue slow process background job here
	
  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.update(
        "resource-status-container",
        partial: "resources/loader",
        locals: { phrases: ["Concocting", "Prepping", "Pouring"] })
    end
    format.html { redirect_to resources_path, notice: "Resource created successfully" }
  end
end

Here’s what’s happening:

  • User submits the form.
  • We immediately respond with a Turbo Stream.
  • That Turbo Stream replaces the empty resource-status-container with our new partial _loader.html.erb, passing along a list of static phrases.

4.2 Build the loader partial
Create app/views/resources/_loader.html.erb:

<div class="flex items-center gap-2">
  <span class="loading loading-ring loading-xl"></span>
  <span class="max-w-14 min-w-14 w-14 text-sm text-start">
    <%= phrases.first %>...
  </span>
</div>

Now when you submit the form, you’ll see a spinner and one phrase (like Concocting...).

4.3 Cycle phrases with Stimulus
Right now it only shows the first phrase. Let’s rotate them every 2 seconds with Stimulus.

Generate a Stimulus controller:

rails g stimulus PhraseCycler

That gives you app/javascript/controllers/phrase_cycler_controller.js. Add this:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="phrase-cycler"
export default class extends Controller {
  static targets = ["label"]
  static values = {
    phrases: Array
  } 

  connect() {
    this.index = 0;
    this.words = this.phrasesValue.map(phrase => `${phrase}...`);
		
    if (this.labelTarget.textContent.trim() === "") {
      this.labelTarget.textContent = this.words[this.index];
    }
	
    this.timer = setInterval(() => {
      this.cycle();
    }, 2000);
  }

  disconnect() {
    setTimeout(() => {
        clearInterval(this.timer);
    }, this.timer);
  }

  cycle() {
    this.index = (this.index + 1) % this.words.length;
    this.labelTarget.textContent = this.words[this.index];
  }
}

4.4 Hook up the Stimulus controller
Update the loader partial to use the Stimulus controller:

<div class="flex items-center gap-2" data-controller="phrase-cycler" data-phrase-cycler-phrases-value="<%= phrases.to_json %>">
  <span class="loading loading-ring loading-xl"></span>
  <span class="max-w-14 min-w-14 w-14 text-sm text-start" data-phrase-cycler-target="label">
      <%= phrases.first %>...
  </span>
</div>

Now, when the loader partial is injected, the Stimulus controller connects and cycles through the phrases automatically every 2 seconds.

Neat, right? We now have a cycling loader that feels alive. Way better than just Loading....

Next up we’ll simulate a slow background job to dismiss the loader, then bring AI into the mix for truly dynamic phrases.

Step 5. Simulating a long-process background job

When ResourcesController#create runs, we also want to enqueue a slow process. Once it’s done, it should swap the loader with either a success or failure message.

rails g job SlowProcess

And here’s the job code:

class SlowProcessJob < ApplicationJob
  queue_as :default

  def perform
    sleep 20
    stream_to_target("success")
  rescue StandardError => _
    stream_to_target("failure")
  end

  private

  def stream_to_target(status)
    Turbo::StreamsChannel.broadcast_update_to(
      "resource_channel",
      target: "resource-status-container",
      partial: "resources/#{status}"
    )
  end
end

What’s happening:

  • We fake a slow process with sleep 20.
  • When it’s done, we stream either success or failure to the resource_channel.
  • That update replaces the loader in resource-status-container.

5.1 Create the success and failure partials

<%# app/views/resources/_success.html.erb %>
<div>
  <span class="text-sm text-green-500">Success!</span>
</div>
<%# app/views/resources/_failure.html.erb %>
<div>
  <span class="text-red-500">Failure!</span>
</div>

5.2 Subscribe the view to a Turbo Stream
At the top of app/views/resources/index.html.erb, add:

<%= turbo_stream_from "resource_channel" %>

5.3 Update the controller action
Don't forget to enqueue the SlowProcessJob in the ResourcesController#create here's the updated controller action

def create
  SlowProcessJob.perform_later
  
  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.append("resource-status-container", partial: "resources/loader", locals: { phrases: [ "Concocting", "Prepping", "Pouring" ] })
    end
  
    format.html { redirect_to resources_path }
  end
end

Now, when you submit the form:

  1. The loader kicks in with cycling phrases.
  2. SlowProcessJob is enqueued and runs in the background.
  3. After ~20 seconds, the job finishes.
  4. The loader is replaced with Success! (or Failure! if something goes wrong).

Awesome. At this point, you’ve got a fully working flow: The user submits the form. An animated loader is displayed. The slow jobs gets enqueued and once finished it streams the success partial.

We can call it a day. But we didn’t come all this way just for hard-coded phrases. Let's go ahead and implement those AI generated loading phrases...

6. AI-Generated loading phrases with RubyLLM

We’ll create a new background job that asks an LLM (like OpenAI) for random loading phrases.

6.1 Create the job

rails g job GenerateLoadingPhrases

This gives us app/jobs/generate_loading_phrases_job.rb

6.2 Configure RubyLLM
First, install the gem:

bundle add ruby_llm

Then add an initializer config/initializers/ruby_llm.rb:

require "ruby_llm"

RubyLLM.configure do |config|
  # Add keys ONLY for the providers you intend to use.
  config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
  config.default_model = "gpt-4o-mini"
  # config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
end

Don’t forget to add your API key to your .env, e.g.:

# .env
OPENAI_API_KEY=123abc

RubyLLM gives us a clean interface to interact with OpenAI, Anthropic, Gemini, DeepSeek, and more. It's a pretty easy and powerful gem to use.

6.3 Add structured output with ruby_llm-schema
We need the AI to return a list of phrases (an array of strings). By default, RubyLLM doesn’t support structured responses. That’s where ruby_llm-schema comes in.

Let's install it.

bundle add ruby_llm-schema

Then, create app/schemas/phrase_schema.rb:

class PhraseSchema < RubyLLM::Schema
  array :phrases, of: :string
end

6.4 Implement the job
Inside app/jobs/generate_loading_phrases_job.rb add this code:

def perform(message)
  phrases = fetch_phrases(message)
  stream_to_target(phrases)
rescue StandardError => _
  # If the job fails, do nothing
  nil
end

private

def fetch_phrases(message)
  chat = RubyLLM.chat
  chat.with_instructions(prompt)
  response = chat.with_schema(PhraseSchema).ask(message)
  response.content["phrases"]
end

def stream_to_target(phrases)
  Turbo::StreamsChannel.broadcast_update_to(
    "resource_channel",
    target: "resource-status-container",
    partial: "resources/loader",
    locals: { phrases: phrases }
  )
end

def prompt
  <<~PROMPT
	Analyze this message and come up with a list of positive, cheerful and delightful verbs in gerund form that's related to the message. Only include words with no other text or punctuation. These words should have the first letter capitalized. Add some quaint and surprise to entertain the user. Ensure each word is highly relevant to the user's message. Obscure words are preferred but be careful to avoid words that might look alarming or concerning to the software engineer seeing it as a status notification, such as Connecting, Disconnecting, Retrying, Lagging, Freezing, etc. NEVER use a destructive word, such as Terminating, Killing, Deleting, Destroying, Stopping, Exiting, or similar. NEVER use a word that may be derogatory, offensive, or inappropriate in a non-coding context, such as Penetrating.
  PROMPT
end

6.5 Update the controller
And finally, enqueue the new job inside ResourcesController#create:

def create
  GenerateLoadingPhrasesJob.set(priority: -10).perform_later(params[:message])
  SlowProcessJob.perform_later
	
  respond_to do |format|
    format.turbo_stream do
      render turbo_stream: turbo_stream.append("resource-status-container", partial: "resources/loader", locals: { phrases: [ "Concocting", "Prepping", "Pouring" ] })
      end
	
      format.html { redirect_to resources_path }
  end
end

Let me explain what's going on here.

6.6 How it works?

  • GenerateLoadingPhrasesJob runs with a higher priority so it finishes before the slow job.
  • It sends a structured request to the LLM (with our schema + prompt).
  • The LLM responds with a list of phrases.
  • The job streams those new phrases to the resource_channel, updating the loader.
  • The Stimulus controller takes over and cycles through them.
  • Once the slow job finishes, it streams Success (or Failure) and replaces the loader.

At this point, you’ve got AI-powered dynamic loading phrases!

The user submits a message, sees playful phrases that actually relate to their input, and then a final success message.

For example:

final result

"Exploring", "Wandering", "Dancing", "Glimmering", "Sparkling", "Fluttering", "Radiating", "Savoring", "Whimsicalizing", "Delighting"

Conclusion

That’s a wrap! In this tutorial, we have learned how to create dynamic loading messages using the AI.

This is a simple but powerful technique to make your apps feel alive and fun. Try tweaking the prompt to see what unique and corny phrases the AI comes up with. That's where the magic happens!

If you enjoyed this tutorial, here's where to find more of my work:

Sign up for my newsletter — I share more deep dives into AI and Rails

To learn more about who I am and what I do:
Check the about page
Follow me on X