Building an image carousel with Rails 7, Stimulus, and Turbo

In an effort to keep the Lighthouse ratings in the green, I built an image carousel that shows low-fi placeholder images (separate topic) and dynamically loads hi-fi images using Turbo. Stimulus provides the controls for switching between images.

Rails 7 comes with Stimulus and Turbo out-of-the-box. Stimulus lets you augment HTML by injecting JavaScript functionality. Turbo gives you the tools to create awesomely fast interfaces without the added complexity of an SPA. Tailwind is used for styling. If there's anything unclear you can check out the docs.

We'll use some stubs and pseudocode in the code examples, but it should be pretty self-explanatory. The sample code assumes a Photo model.

We'll start with a basic carousel that just shows the first image.

<!-- app/views/photos/_carousel.html.erb -->
<div>
  <% @photos.each_with_index do |photo, idx| %>
    <div class="<%= idx > 0 ? "hidden" : nil %>">
      <img
        class="rounded-md"
        width="<%= photo.display_width %>"
        height="<%= photo.display_height %>"
        src="<%= photo.image_url %>"
      >
    </div>
  <% end %>
</div>

Switching between images

Now let's add some carousel controls. To position them we'll add some classes to the carousel wrapper. We'll use relative positioning on the carousel so we can use absolute on the controls. This will help us vertically align the actual button tags with a flexbox layout.

<!-- app/views/photos/_carousel.html.erb -->
<div>
<div class="relative">
  <div class="absolute left-0 bottom-0 top-0 flex items-center">
    <button type="button" class="rounded-full bg-gray-300"><!-- previous icon --></button>
  </div>
  <div class="absolute right-0 bottom-0 top-0 flex items-center">
    <button type="button" class="rounded-full bg-gray-300"><!-- next icon --></button>
  </div>
  <% @photos.each_with_index do |photo, idx| %>
    <div class="<%= idx > 0 ? "hidden" : nil %>">
      <img
        class="rounded-md"
        width="<%= photo.display_width %>"
        height="<%= photo.display_height %>"
        src="<%= photo.image_url %>"
      >
    </div>
  <% end %>
</div>

Stimulus controller

Let's create our Stimulus controller with:

rails g stimulus carousel

Let's also add the next and previous methods, but leave them empty for now.

// app/javascript/controllers/carousel_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="carousel"
export default class extends Controller {
  connect() {
  }
  next() {
  }
  previous() {
  }
}

Hooking up the controller

First we'll attach the controller to our carousel element.

<!-- app/views/photos/_carousel.html.erb -->
<div class="relative">
<div class="relative" data-controller="carousel">
  <!-- controls -->
  <!-- photos -->
</div>

Next let's hook up the previous and next buttons to our controller previous() and next() methods respectively.

<!-- app/views/photos/_carousel.html.erb -->
<div class="relative" data-controller="carousel">
  <div class="absolute left-0 bottom-0 top-0 flex items-center">
    <button type="button" class="rounded-full bg-gray-300"><!-- previous icon --></button>
    <button data-action="click->carousel#previous" type="button" class="rounded-full bg-gray-300"><!-- previous icon --></button>
  </div>
  <div class="absolute right-0 bottom-0 top-0 flex items-center">
    <button type="button" class="rounded-full bg-gray-300"><!-- next icon --></button>
    <button data-action="click->carousel#next" type="button" class="rounded-full bg-gray-300"><!-- next icon --></button>
  </div>
  <!-- photos -->
</div>

Now we need to set each of the images in the carousel as a target for the controller by setting data-carousel-target="image", so we can hide and display them as necessary.

<!-- app/views/photos/_carousel.html.erb -->
<div class="relative" data-controller="carousel">
  <!-- controls -->
  <% @photos.each_with_index do |photo, idx| %>
    <div class="<%= idx > 0 ? "hidden" : nil %>">
    <div data-carousel-target="image" class="<%= idx > 0 ? "hidden" : nil %>">
      <!-- img tag -->
    </div>
  <% end %>
</div>

Loading hi-fi images

To load hi-fi images, we put a lazy loaded Turbo frame into our DOM. We'll use absolute positioning so it sits above the lo-fi placeholder.

<!-- app/views/photos/_carousel.html.erb -->
<% @photos.each_with_index do |photo, idx| %>
  <div data-carousel-target="image" class="<%= idx > 0 ? "hidden" : nil %>">
    <%=
      turbo_frame_tag "photo-#{photo.id}",
        class: "absolute top-0 left-0 right-0 bottom-0",
        src: photo.image_url,
        loading: :lazy
      %>
  </div>
<% end %>

Switching between images

Now that we've set up our Turbo frames for each image we can make our carousel controls switch between images. When a new image is displayed, the Turbo frame will load the hi-fi image and replace (cover) the lo-fi placeholder.

// app/javascript/controllers/carousel_controller.js
export default class extends Controller {
  static targets = ["image"]
  connect() {
    this.index = 0
  }
  previous() {
    if (this.index > 0) {
      this.imageTargets[this.index].classList.add("hidden")
      this.index -= 1
      this.imageTargets[this.index].classList.remove("hidden")
    }
  }
  next() {
    if (this.index < this.imageTargets.length - 1) {
      this.imageTargets[this.index].classList.add("hidden")
      this.index += 1
      this.imageTargets[this.index].classList.remove("hidden")
    }
  }
}

This code is pretty simple: first hide the current image being displayed (by default, it's the first one), then increment/decrement the index of the image being displayed. Then show the image at this updated index.

Full code

Below is the full code for the solution. There are still some stubs, like icons for the previous/next buttons, and the styling might need some tweaks to make it look sweet, sweet nice.

<!-- app/views/photos/_carousel.html.erb -->
<div class="relative" data-controller="carousel">
  <div class="absolute left-0 bottom-0 top-0 flex items-center">
    <button type="button" class="rounded-full bg-gray-300"><!-- previous icon --></button>
  </div>
  <div class="absolute right-0 bottom-0 top-0 flex items-center">
    <button type="button" class="rounded-full bg-gray-300"><!-- next icon --></button>
  </div>
  <% @photos.each_with_index do |photo, idx| %>
    <img
      width="<%= photo.display_width %>"
      height="<%= photo.display_height %>"
      src="<%= photo.placeholder_image_url %>"
    >
    <div data-carousel-target="image" class="<%= idx > 0 ? "hidden" : nil %>">
      <%=
        turbo_frame_tag "photo-#{photo.id}",
          class: "absolute top-0 left-0 right-0 bottom-0",
          src: photo.image_url,
          loading: :lazy
        %>
    </div>
  <% end %>
</div>
// app/javascript/controllers/carousel_controller.js
export default class extends Controller {
  static targets = ["image"]
  connect() {
    this.index = 0
  }
  previous() {
    if (this.index > 0) {
      this.imageTargets[this.index].classList.add("hidden")
      this.index -= 1
      this.imageTargets[this.index].classList.remove("hidden")
    }
  }
  next() {
    if (this.index < this.imageTargets.length - 1) {
      this.imageTargets[this.index].classList.add("hidden")
      this.index += 1
      this.imageTargets[this.index].classList.remove("hidden")
    }
  }
}

Afterthoughts

  • The Turbo frame for the hi-fi image currently just loads the raw image URL. Remark's implementation uses a special Photo endpoint /photos/:id which renders a Photo partial. This allows access control, tracking view count, etc.
  • This is a pretty simple solution. You can step up the UX by disabling the controls when the carousel is showing the first/last image, hiding them unless the carousel is hovered and has more than one image, animations, supporting swipe gestures on touch screens, etc.
  • Remark's carousel has a starting image index value that can be passed to the carousel to change which image is visible by default. That's another addition you could make to enhance the flexibility of the carousel.
  • If you want a "normal" carousel that automatically switches between images, you can do this with setInterval in your Stimulus controller (make sure you remove it with clearInterval in the disconnect method!). You'll just update the carousel index with:

    this.indexValue = (this.indexValue + 1) % this.imageTargets.length

    If you do this, it might also be sensible to reset the interval when one of the controls is clicked.