In this article, we’re going to learn how to create an image slideshow using Ruby on Rails, Hotwire, and Tailwind CSS. We’ll also sync the navigation between the images across multiple windows.
Our goal is to achieve the following result:
As you can see, there are two windows. On the left, a user who is logged in manages the slideshow navigation, while on the right, a user who is not logged in views the same slideshow and the images change automatically.
We’ll make this happen without writing any JavaScript code, using Ruby on Rails + Tailwind CSS + Hotwire.
You can find the code for everything described here in this GitHub repository.
Modeling the Data
To build the solution, we’ll work with just two tables: photos
and users
.
We’ll use the users table to handle the user session, since the user controlling the photo navigation will be logged in.
For the photos, we’ll have a title and the photo’s URL. To do this, let’s create the associated model and run the migrations:
rails generate model Photo title url
rails db:migrate
As I mentioned, the person presenting the slideshow needs to be registered and logged in. To make this happen, we’ll use the devise gem:
bundle add devise
rails generate devise:install
rails generate devise User
With these commands, you will have devise configured and the users table created.
Building the Slideshow
For the slideshow, we will create a controller and an index
action to display the first image:
rails generate controller slideshow index
This will create our controller with the index action and generate a view in app/views/slideshow/index.html.erb
.
class SlideshowController < ApplicationController
def index
end
end
Let’s replace the code in app/views/slideshow/index.html.erb
with the following code to display a slideshow featuring an image with a title, along with controls to navigate to the next/previous image:
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<!-- IMAGE TITLE -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
EXAMPLE TITLE
</p>
<!-- IMAGE -->
<div class="relative overflow-hidden rounded-lg">
<img src="EXAMPLE_URL" class="w-full" />
</div>
<!-- NAV CONTROLS -->
<button class="absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</span>
</button>
<button class="absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none" />
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</span>
</button>
</div>
</div>
If you assign a valid URL to the src
attribute of the <img>
tag, you will see something like this:
Finally, we can tidy up the view by extracting the code for the photo and controls into a partial. Our index.html.erb
will then look like this:
<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= render 'photos/photo' %>
</div>
</div>
And here’s what our partial would look like:
<!-- app/views/photos/_photo.html.erb -->
<!-- IMAGE TITLE -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
EXAMPLE TITLE
</p>
<!-- IMAGE -->
<div class="relative overflow-hidden rounded-lg">
<img src="EXAMPLE_URL" class="w-full" />
</div>
<!-- NAV CONTROLS -->
<button class="absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
</svg>
</span>
</button>
<button class="absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none" />
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-4 h-4 text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</span>
</button>
Displaying an Actual Photo
The example we’ve created so far has hardcoded information, which means it doesn’t get data from the database. To fix this, we need to update our controller to fetch a photo from the database and use it in the partial.
Let’s modify our controller to retrieve a photo from the database:
# app/controllers/slideshow_controller.rb
class SlideshowController < ApplicationController
def index
@photo = Photo.first
end
end
Let’s use the @photo
variable in our index to render the app/views/photos/_photo.html.erb
partial:
<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= render @photo %>
</div>
</div>
Finally, let’s use the title
and url
attributes to display the information corresponding to the photo from the database:
<!-- app/views/photos/_photo.html.erb -->
<!-- IMAGE TITLE -->
<p class="my-2 text-center text-xl font-semibold text-gray-800">
<%= photo.title %>
</p>
<!-- IMAGE -->
<div class="relative overflow-hidden rounded-lg">
<%= image_tag photo.url, class: 'w-full' %>
</div>
<!-- NAV CONTROLS -->
...
This way, if you have photos in your database, when you access the index, the first photo will be displayed.
Navigating Between Photos
Now, the fun part starts! 🧐 Let’s make changes to allow the user to switch between photos. To do this, we’ll add the previous
and next
actions to our controller and use a Turbo Frame to update the currently displayed photo.
Adding a Turbo Frame
Since we want the image navigation to be seamless without reloading the entire page, we’ll add a Turbo Frame.
Here’s what the official documentation says about Turbo Frames:
Turbo Frames allow predefined parts of a page to be updated on request. Any links and forms inside a frame are captured, and the frame contents automatically updated after receiving a response. Regardless of whether the server provides a full document, or just a fragment containing an updated version of the requested frame, only that particular frame will be extracted from the response to replace the existing content.
Basically, with Turbo Frames we can navigate within the same frame without having to reload the whole page. This makes the user experience smoother and more efficient.
Let’s add our Turbo Frame to the partial:
<!-- app/views/photos/_photo.html.erb -->
<%= turbo_frame_tag :photo do %>
<!-- IMAGE TITLE -->
...
<!-- IMAGE -->
...
<!-- NAV CONTROLS -->
...
<% end %>
This way, when you click on the navigation controls, the response to the request will be rendered in the frame.
Implementing the Navigation
Navigation will be super easy. We just need to add two new routes and their corresponding actions to our slideshow_controller
. These actions will take the ID of the current photo being displayed and show the next or previous one based on that ID:
def next
# If it's the last photo, it assigns the first one.
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
render @photo # Renders _photo partial
end
def previous
# If it's the first photo, it assigns the last one
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
render @photo # Renders _photo partial
end
Let’s add the routes:
resources :slideshow, only: %i[index] do
member do
post 'next'
post 'previous'
end
end
Finally, let’s update the controls to point to the new routes:
<%= button_to previous_slideshow_path(photo), class: 'absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>
<%= button_to next_slideshow_path(photo), class: 'absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>
Great! With these changes, you can now view your slideshow and navigate between photos seamlessly:
Synchronizing Photos Across Multiple Windows
Now, let’s make things a bit more complicated and have the slideshow sync across multiple windows. Basically, one person controls the slideshow and everyone else automatically sees the same image.
With our current solution, if we open a new incognito window next to the one we’re using, they each have their own navigation. Changing slides in one window doesn’t affect the other.
Hiding Navigation Controls
The first thing we’ll do is make sure that only one user can see the controls. To do this, we’ll use the Devise gem we added earlier. The only user who should see the controls is the one who is logged in. Let’s add the following condition in the view, and to make our code neater, let’s move our controls to a new partial.
<!-- app/views/photos/_photo.html.erb -->
<%= turbo_frame_tag :photo do %>
<!-- IMAGE TITLE -->
...
<!-- IMAGE -->
...
<!-- NAV CONTROLS -->
<!-- Devise provides us with the method user_signed_in? to check if our user is logged in -->
<%= render 'photos/controls', photo: photo if user_signed_in? %>
<% end %>
<!-- app/views/photos/_controls.html.erb -->
<%= button_to previous_slideshow_path(photo), class: 'absolute left-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>
<%= button_to next_slideshow_path(photo), class: 'absolute right-0 z-30 top-1/2 px-4 cursor-pointer group focus:outline-none' do %>
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
...
</span>
<% end %>
Additionally, let’s add the following to app/views/layouts/application.html.erb
to display the logged-in user:
<%= content_tag :p, class: 'text-sm' do %>
<% if user_signed_in? %>
You are the Presenter
<%= link_to 'Sign out', destroy_user_session_path, data: { turbo_method: :delete }, class: 'underline decoration-sky-500' %>
<% else %>
You are a viewer
<% end %>
<% end %>
Now, if we log in on the left window (which can be done through localhost:3000/users/sign_in thanks to devise), that will be the user who sees the controls:
Synchronizing Navigation
So, what we’re going to do is sync both windows. Basically, when the user on the left clicks the next or previous button, the photo on the right will change automatically.
To make this happen, we’ll use Turbo Streams.
According to the official documentation, Turbo Streams work like this:
Turbo Streams deliver page changes as fragments of HTML wrapped in self-executing
<turbo-stream>
elements. Each stream element specifies an action together with a target ID to declare what should happen to the HTML inside it. These elements are delivered by the server over a WebSocket, SSE or other transport to bring the application alive with updates made by other users or processes
Basically, we can update a part of our pages directly from our controller using WebSockets. Plus, we can apply these changes to multiple sessions using broadcasts.
First, we’ll replace the Turbo Frame with a div
since we don’t need it anymore. We’ll keep the id
as “photo” because we’ll use it later to replace the content with our Turbo Stream:
<!-- app/views/photos/_photo.html.erb -->
<%= content_tag :div, id: :photo do %>
<!-- IMAGE TITLE -->
...
<!-- IMAGE -->
...
<!-- NAV CONTROLS -->
...
<% end %>
In addition, we’ll need to specify a channel to listen for the change received from our controller. We’ll do this in the index.html.erb
through the turbo_stream_from
method:
<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= turbo_stream_from(:photos) %>
<%= render 'photos/photo' %>
</div>
</div>
In other words, all actions sent via Turbo Stream on the “photos” channel will be applied.
Next, we need to update our controller actions to let everyone know about the change. What we want to do is replace the element with the ID “photo” with the photos/photos
partial.
def next
# If it's the last photo, it assigns the first one
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
Turbo::StreamsChannel.broadcast_replace_to(
:photos, # Channel through which we broadcast the change.
target: 'photo', # ID of the element we want to replace.
partial: 'photos/photo',
locals: { photo: @photo }
)
end
def previous
# If it's the first photo, it assigns the last one
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
end
Just as there is
broadcast_replace_to
, you can do the same withbroadcast_remove
,broadcast_append
, andbroadcast_prepend
.
If we press Next or Previous in our slideshow, we will encounter the following error:
This happens because the parts used for Turbo Streaming are rendered by ApplicationRenderer
instead of within the context of our request. In this case, Devise is trying to access our logged-in user, causing problems.
To fix this, we could move the _controls.html.erb
part into index.html.erb
to make sure that the use of the user_signed_in?
method is not within the streaming.
It would look like this:
<!-- app/views/photos/_photo.html.erb -->
<%= content_tag :div, id: :photo do %>
<!-- IMAGE TITLE -->
...
<!-- IMAGEN -->
...
<!-- 🧹 We removed the invocation of the partial from here 🧹 -->
<% end %>
<!-- app/views/slideshow/index.html.erb -->
<div class="mx-auto w-9/12 h-screen">
<div class="relative w-full mt-3">
<%= turbo_stream_from(:photos) %>
<%= render @photo %>
<!-- ✅ We moved here the invocation of the partial -->
<%= render 'photos/controls', photo: @photo if user_signed_in? %>
</div>
</div>
With this change, we‘ll make sure that the photos are updated accordingly. The current issue is that the navigation buttons are not being updated, and they consistently point to the same photo. It used to work seamlessly because they were part of the same partial template.
To fix this, we need to add another stream to our controller:
class SlideshowController < ApplicationController
def index
@photo = Photo.first
end
def next
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'controls',
partial: 'photos/controls',
locals: { photo: @photo }
)
end
def previous
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'photo',
partial: 'photos/photo',
locals: { photo: @photo }
)
Turbo::StreamsChannel.broadcast_replace_to(
:photos,
target: 'controls',
partial: 'photos/controls',
locals: { photo: @photo }
)
end
end
Great! With these changes, our slideshow is synchronized between the windows.
Finally, we could refactor our controller and move the streaming logic to its own view. To do this, let’s extract the logic for broadcasting to a file named app/views/slideshow/photo.turbo_stream.erb
:
# app/views/slideshow/photo.turbo_stream.erb
<%
Turbo::StreamsChannel.broadcast_replace_to(:photos, target: 'photo', partial: 'photos/photo', locals: { photo: @photo })
Turbo::StreamsChannel.broadcast_replace_to(:photos, target: 'controls', partial: 'photos/controls', locals: { photo: @photo })
%>
And let’s modify our controller as follows:
class SlideshowController < ApplicationController
def index
@photo = Photo.first
end
def next
@photo = Photo.find_by('id > ?', params[:id].to_i) || Photo.first
render :photo
end
def previous
@photo = Photo.order(id: :desc).find_by('id < ?', params[:id].to_i) || Photo.last
render :photo
end
end
As Turbo has captured our link, it will invoke the view at app/views/slideshow/photo.turbo_stream.erb
, so everything will continue to work.