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:

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
andruby_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
andResourcesController#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 loadResourcesController#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
orfailure
to theresource_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:
- The loader kicks in with cycling phrases.
SlowProcessJob
is enqueued and runs in the background.- After ~20 seconds, the job finishes.
- 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:

"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