Infinite Horizontal Slider with CableReady and the Intersection Observer API
Tags: rails, cableready, javascript
Published: 2020-07-22 08:14:27

---
title: Infinite Horizontal Slider with CableReady and the Intersection Observer API
published: true
date: 2020-07-22 00:00:00 UTC
tags: rails,cableready,javascript
canonical_url: https://blog.minthesize.com/horizontal-slider-intersection-observer-cable-ready
---

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](https://cableready.stimulusreflex.com/) and one of Adrien Poly’s [stimulus-use](https://github.com/stimulus-use/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](https://ddnexus.github.io/pagy/) gem. Let’s get started by creating a new Rails app and installing all the dependencies.

```sh
$ 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
```

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

```sh
$ bin/yarn add tailwindcss
$ npx tailwindcss init
```

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

```js
@import "tailwindcss/base";

@import "tailwindcss/components";

@import "tailwindcss/utilities";
```

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

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

import "../styles/application";

import "controllers";
```

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

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

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

```erb
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
```

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

```ruby
class SliderChannel < ApplicationCable::Channel
  def subscribed
    stream_from "slider-stream"
  end
end
```

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:

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

consumer.subscriptions.create("SliderChannel", {
  received(data) {
    if (data.cableReady) CableReady.perform(data.operations);
  }
});
```

# 2. Backend Necessities

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

```sh
$ bin/rails g scaffold Item --no-javascripts --no-assets --no-helper
$ bin/rails db:migrate
$ bin/rails r "1000.times { Item.create }"
```

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:

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

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

  # ...
end
```

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.

```erb
<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>
```

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.

```erb
<%= 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 %>
```

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

```erb
<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>
```

# 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:

```js
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");
  }
}
```

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`:

```ruby
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
```

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](https://dev-to-uploads.s3.amazonaws.com/i/57jfr73yddq3v69gnioy.gif)

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:

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

![Horizontal Slider 2](https://dev-to-uploads.s3.amazonaws.com/i/flw0pczin44k7bpmqylf.gif)

## Further Reading

- If you’d like to know more about how you can use CableReady, head over to [cableready.stimulusreflex.com](https://cableready.stimulusreflex.com/)
- @hopsoft recently published a short (2.5 min) overview video about how CableReady works: [https://www.youtube.com/watch?v=dPzv2qsj5L8](https://www.youtube.com/watch?v=dPzv2qsj5L8)
- there’s also a free [Gorails](https://gorails.com/) episode on CableReady: [https://www.youtube.com/watch?v=grnQ46lNDAc](https://www.youtube.com/watch?v=grnQ46lNDAc)