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

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

$ 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.

@import "tailwindcss/base";

@import "tailwindcss/components";

@import "tailwindcss/utilities";

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


import "../styles/application";

import "controllers";

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

module.exports = {
  plugins: [
  // ...

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' %>

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

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

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);

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 }"

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

  # ...

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.


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

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.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: + 1, format: :js)  %>">
    <div class="flex w-full h-full justify-center items-center">&nbsp;</div> 
<% end %>

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">
    <%=  %>

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() {

  loadMore(url) {
      type: "GET",
      url: url

  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:

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.js do
          selector: "#slider-sentinel",
          focusSelector: "#slider-sentinel",
          html: render_to_string(partial: "items/items", locals: { items: @items, pagy: @pagy }, layout: false, content_type: "text/html") # <--

  # ...

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


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


Further Reading