Infinite Horizontal Slider with CableReady and the Intersection Observer API

julianrubisch

julianrubisch

Posted on July 22, 2020

Infinite Horizontal Slider with CableReady and the Intersection Observer API

Most IntersectionObserver demos show off how to implement infinite scrolling for news feeds and similar use cases. One of my most recent encounters, though, was concerned with a product image slider, horizontally of course. In the not so far away past, this would have meant crafting JSON endpoints to obtain paginated resources, render them as HTML and write all the necessary glue code, easily the workload of a full day. With CableReady and one of Adrien Poly’s stimulus-use controllers, this can all be done in a very descriptive way in just a few lines of code.

1. Setup

To demonstrate this, I’m going to use the pagy gem. Let’s get started by creating a new Rails app and installing all the dependencies.

$ rails new horizontal-slider-cable-ready
$ cd horizontal-slider-cable-ready
$ bundle add cable_ready pagy
$ bin/yarn add cable_ready stimulus-use
$ bin/rails webpacker:install
$ bin/rails webpacker:install:stimulus
Enter fullscreen mode Exit fullscreen mode

To get some styling for our demo, let’s also set up tailwind quickly:

$ bin/yarn add tailwindcss
$ npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

Create app/javascript/styles/application.scss, adding the tailwind setup and an intentionally ugly styling for the observer sentinel.

@import "tailwindcss/base";

@import "tailwindcss/components";

@import "tailwindcss/utilities";
Enter fullscreen mode Exit fullscreen mode

In app/javascript/packs/appliction.js, add the stylesheet:

require("@rails/ujs").start();
require("turbolinks").start();
require("@rails/activestorage").start();
require("channels");

import "../styles/application";

import "controllers";
Enter fullscreen mode Exit fullscreen mode

Because tailwind is a postcss plugin, we need to set it up in postcss.config.js:

module.exports = {
  plugins: [
    require("autoprefixer"),
    require("tailwindcss")("tailwind.config.js"),
    // ...
  ]
}
Enter fullscreen mode Exit fullscreen mode

Furthermore, in app/views/layouts/application.html.erb, exchange stylesheet_link_tag with stylesheet_pack_tag:

<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
Enter fullscreen mode Exit fullscreen mode

For our CableReady setup, let’s create a SliderChannel (app/channels/slider_channel.rb)

class SliderChannel < ApplicationCable::Channel
  def subscribed
    stream_from "slider-stream"
  end
end
Enter fullscreen mode Exit fullscreen mode

along with the JavaScript counterpart in app/javascript/channels/slider_channel.js, where in the receive hook, we instruct CableReady to actually perform its operations:

import CableReady from "cable_ready";
import consumer from "./consumer";

consumer.subscriptions.create("SliderChannel", {
  received(data) {
    if (data.cableReady) CableReady.perform(data.operations);
  }
});
Enter fullscreen mode Exit fullscreen mode

2. Backend Necessities

So much for the boilerplate. To efficiently test our implementation, let’s create an Item scaffold and 1000 instances:

$ bin/rails g scaffold Item --no-javascripts --no-assets --no-helper
$ bin/rails db:migrate
$ bin/rails r "1000.times { Item.create }"
Enter fullscreen mode Exit fullscreen mode

Now, let’s dive into the interesting stuff. Because we don’t want to load all 1000 instances of Item right away, we’re going to adapt the index action in app/controllers/items_controller.erb to use pagination:

class ItemsController < ApplicationController
  include Pagy::Backend # <-- 

  # GET /items
  # GET /items.json
  def index
    @pagy, @items = pagy Item.all, items: 10 # <--
  end

  # ...
end
Enter fullscreen mode Exit fullscreen mode

In the app/views/items/index.html.erb view, we create a container for the slider and add CSS to set the appropriate overflow and white-space attributes, so that we can scroll horizontally and to avoid line breaks.

<h1>Items</h1>
<div id="slider-container" class="w-screen overflow-x-scroll overflow-y-none whitespace-no-wrap">
  <%= render "items/items", items: @items, pagy: @pagy %>
</div>
Enter fullscreen mode Exit fullscreen mode

Within app/views/items/_items.html.erb, we render the items collection, along with the slider-sentinel. This last piece of markup is the central building block of our implementation: Whenever it comes into the viewport, it is going to trigger lazy loading of new items from the server. To do this, we instrument it with a lazy-load stimulus controller that we are going to write in the next step, along with the URL to fetch when it comes into view. We simply use the items_path here and pass the next page, and js as a format (which I’ll come back to later).

The last bit of explanation necessary here concerns the if conditional the sentinel is wrapped in: When there are no more pages to load, we don’t want to display it because it will only lead to a 404 when trying to fetch a page that doesn’t exist.

<%= render items %>

<% if pagy.page < pagy.last %>

  <div id="slider-sentinel" class="inline-block w-4 h-48 text-3xl bg-orange-500" data-controller="lazy-load" data-lazy-load-next-url="<%= items_path(page: pagy.page + 1, format: :js) %>">
    <div class="flex w-full h-full justify-center items-center"> </div>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

For completeness sake, here’s our app/views/items/_item.html.erb partial:

<div class="w-64 h-48 text-3xl border border-gray-400">
  <div class="flex w-full h-full justify-center items-center">
    <%= item.id %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

3. Adding Frontend Reactivity

Okay, now it’s time to write the necessary JS sprinkles: in app/javascript/controllers/lazy_load_controller.js, we import useIntersection from the excellent stimulus-use library and call it in the connect callback of our controller. Essentially, this instruments our controller, or rather the DOM element it is attached to, with an IntersectionObserver that will call the controller’s appear method once it slides into the viewport.

So we implement this method and have it fetch more content via Rails.ajax and the url we specified above when attaching the controller to the sentinel:

import { Controller } from "stimulus";
import { useIntersection } from "stimulus-use";
import Rails from "@rails/ujs";

export default class extends Controller {
  connect() {
    useIntersection(this, {
      rootMargin: "0px 0px 0px 0px",
      root: document.querySelector("#slider-container"),
      threshold: 0
    });
  }

  appear() {
    this.loadMore(this.nextUrl);
  }

  loadMore(url) {
    Rails.ajax({
      type: "GET",
      url: url
    });
  }

  get nextUrl() {
    return this.data.get("nextUrl");
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let’s get to the real meat - we include CableReady::Broadcaster in our items_controller.rb and split our logic between different formats. This is mainly a trick to avoid writing a second controller action plus routing, when everything is already so neatly set up.

In the format.js block, we set up CableReady to exchange the sentinel’s outer_html (i.e., itself) with the contents of the next page’s partial (which, as you can inspect above, includes a new sentinel again). It’s this recursive structure that makes this approach especially elegant.

Observe that we call render_to_string with layout: false and set the content_type to text/html:

class ItemsController < ApplicationController
  include Pagy::Backend
  include CableReady::Broadcaster # <--

  # GET /items
  # GET /items.json
  def index
    @pagy, @items = pagy Item.all, items: 10

    respond_to do |format| # <-- 
      format.html
      format.js do
        cable_ready["slider-stream"].outer_html(
          selector: "#slider-sentinel",
          focusSelector: "#slider-sentinel",
          html: render_to_string(partial: "items/items", locals: { items: @items, pagy: @pagy }, layout: false, content_type: "text/html") # <--
        )
        cable_ready.broadcast
      end
    end
  end

  # ...
end
Enter fullscreen mode Exit fullscreen mode

Now when we scroll to the right, we briefly see that orange bar appearing while simultaneously the next 10 items are loaded:

Horizontal Slider 1

We can of course utilize all available IntersectionObserver options to adjust the behavior. For example, by setting rootMargin to 0px 100px 0px 0px new content is loaded before the sentinel even appears in the viewport by (invisibly) extending the bounding box:

connect() {
  useIntersection(this, {
    rootMargin: "0px 100px 0px 0px", // <--
    root: document.querySelector("#slider-container"),
    threshold: 0
  });
}
Enter fullscreen mode Exit fullscreen mode

Horizontal Slider 2

Further Reading

💖 💪 🙅 🚩
julianrubisch
julianrubisch

Posted on July 22, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related