Infinite Scroll Shared Partial

Follow the Installation section first!
This component requires additional setup (dependencies, gems, controllers, partials, or libraries) before using these shared partials. Please complete the Installation section on this page first.

1. Copy shared partial files

Download all 1 shared partial files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the infinite_scroll/ folder to your app/views/shared/components/ directory.

Copy these files to your app/views/shared/components/infinite_scroll/ directory:

2. Usage example

<%
  preview_product = Struct.new(:id, :name, :description, keyword_init: true)
  preview_activity = Struct.new(:id, :description, keyword_init: true)
  products = @products.presence || Array.new(30) do |index|
    preview_product.new(
      id: index + 1,
      name: "Workspace template #{index + 1}",
      description: "A flexible starting point your team can customize."
    )
  end
  activities = @activities.presence || Array.new(30) do |index|
    preview_activity.new(
      id: index + 1,
      description: "Team update #{index + 1} was added to the workspace."
    )
  end
  next_page = @pagy&.next
  previewing = @pagy.nil?
  visible_products = previewing ? products.first(6) : products
  visible_activities = previewing ? activities.first(6) : activities
  products_next_url = if next_page
    products_path(page: next_page)
  elsif previewing
    infinite_scroll_items_path(page: 2, preview: "products")
  end
  activities_next_url = if next_page
    activities_path(page: next_page)
  elsif previewing
    infinite_scroll_items_path(page: 2, preview: "activities")
  end
%>

<!-- A page-level grid with completely custom card content. -->
<%= render "shared/components/infinite_scroll/infinite_scroll",
  next_url: products_next_url,
  manual: true,
  scroll_root: "container",
  viewport_classes: "max-h-[32rem] overflow-y-auto overscroll-contain",
  items_classes: "grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3",
  classes: "w-full overflow-hidden" do %>
  <% visible_products.each do |product| %>
    <article id="product_<%= product.id %>" class="rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <h3 class="font-semibold text-neutral-950 dark:text-white"><%= product.name %></h3>
      <p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400"><%= product.description %></p>
    </article>
  <% end %>
<% end %>

<!-- An internally scrolling semantic list. -->
<%= render "shared/components/infinite_scroll/infinite_scroll",
  next_url: activities_next_url,
  scroll_root: "container",
  items_tag: "ol",
  viewport_classes: "max-h-[32rem] overflow-y-auto overscroll-contain",
  items_classes: "divide-y divide-neutral-200 dark:divide-neutral-800" do %>
  <% visible_activities.each do |activity| %>
    <li id="activity_<%= activity.id %>" class="p-4"><%= activity.description %></li>
  <% end %>
<% end %>

<!-- The JSON endpoint returns item HTML only; every top-level element is appended. -->
<%# render json: { html: render_to_string(partial: "products/products", locals: { products: @products }), next_url: @next_page_url } %>

Rendered usage example

Workspace template 1

A flexible starting point your team can customize.

Workspace template 2

A flexible starting point your team can customize.

Workspace template 3

A flexible starting point your team can customize.

Workspace template 4

A flexible starting point your team can customize.

Workspace template 5

A flexible starting point your team can customize.

Workspace template 6

A flexible starting point your team can customize.

  1. Team update 1 was added to the workspace.
  2. Team update 2 was added to the workspace.
  3. Team update 3 was added to the workspace.
  4. Team update 4 was added to the workspace.
  5. Team update 5 was added to the workspace.
  6. Team update 6 was added to the workspace.

Infinite Scroll ViewComponent

Follow the Installation section first!
This component requires additional setup (gems, controllers, partials, or libraries) before using this ViewComponent. Please complete the Installation section on this page first.

1. Ensure the gem is installed

gem "view_component", "~> 4.2"

2. Copy component files

Download all 2 component files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the infinite_scroll/ folder to your app/components/ directory.

Copy these files to your app/components/infinite_scroll/ directory:

3. Usage example

<%
  preview_product = Struct.new(:id, :name, :description, keyword_init: true)
  preview_activity = Struct.new(:id, :description, keyword_init: true)
  products = @products.presence || Array.new(30) do |index|
    preview_product.new(
      id: index + 1,
      name: "Workspace template #{index + 1}",
      description: "A flexible starting point your team can customize."
    )
  end
  activities = @activities.presence || Array.new(30) do |index|
    preview_activity.new(
      id: index + 1,
      description: "Team update #{index + 1} was added to the workspace."
    )
  end
  next_page = @pagy&.next
  previewing = @pagy.nil?
  visible_products = previewing ? products.first(6) : products
  visible_activities = previewing ? activities.first(6) : activities
  products_next_url = if next_page
    products_path(page: next_page)
  elsif previewing
    infinite_scroll_items_path(page: 2, preview: "products")
  end
  activities_next_url = if next_page
    activities_path(page: next_page)
  elsif previewing
    infinite_scroll_items_path(page: 2, preview: "activities")
  end
%>

<!-- A page-level responsive card grid. Each card is a direct child of the items container. -->
<%= render(InfiniteScroll::Component.new(
  next_url: products_next_url,
  manual: true,
  scroll_root: :container,
  viewport_classes: "max-h-[32rem] overflow-y-auto overscroll-contain",
  items_classes: "grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3",
  classes: "w-full overflow-hidden"
)) do %>
  <% visible_products.each do |product| %>
    <article id="product_<%= product.id %>" class="rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <h3 class="font-semibold text-neutral-950 dark:text-white"><%= product.name %></h3>
      <p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400"><%= product.description %></p>
    </article>
  <% end %>
<% end %>

<!-- An internally scrolling semantic activity list. -->
<%= render(InfiniteScroll::Component.new(
  next_url: activities_next_url,
  scroll_root: :container,
  items_tag: :ol,
  viewport_classes: "max-h-[32rem] overflow-y-auto overscroll-contain",
  items_classes: "divide-y divide-neutral-200 dark:divide-neutral-800",
  classes: "overflow-hidden rounded-xl border border-black/10 dark:border-white/10"
)) do %>
  <% visible_activities.each do |activity| %>
    <li id="activity_<%= activity.id %>" class="p-4"><%= activity.description %></li>
  <% end %>
<% end %>

<!-- The JSON endpoint returns item HTML only; every top-level element is appended. -->
<%# render json: { html: render_to_string(partial: "products/products", locals: { products: @products }), next_url: @next_page_url } %>

Rendered usage example

Workspace template 1

A flexible starting point your team can customize.

Workspace template 2

A flexible starting point your team can customize.

Workspace template 3

A flexible starting point your team can customize.

Workspace template 4

A flexible starting point your team can customize.

Workspace template 5

A flexible starting point your team can customize.

Workspace template 6

A flexible starting point your team can customize.

  1. Team update 1 was added to the workspace.
  2. Team update 2 was added to the workspace.
  3. Team update 3 was added to the workspace.
  4. Team update 4 was added to the workspace.
  5. Team update 5 was added to the workspace.
  6. Team update 6 was added to the workspace.

Infinite Scroll Rails Component

Load small, server-rendered batches as the user reaches the end of a scrollable feed.

Installation

1. Add the Pagy gem

Add the Pagy gem to your Gemfile. I recommend v43+ for the modern API and built-in Turbo friendliness.

gem 'pagy', '~> 43.5.6'

Then run bundle install to install the gem.

2. Configure Pagy in Rails

Include the pagy method where you are going to use it. It will usually be in your application_controller.rb file:

class ApplicationController < ActionController::Base
  include Pagy::Method
  # ...
end

For production infinite feeds, paginate a uniquely ordered Active Record relation with Pagy's :keyset paginator.

3. Install the Stimulus controller

Add the controller to app/javascript/controllers/infinite_scroll_controller.js. Importmap and Stimulus Rails will register it automatically.

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["viewport", "list", "sentinel", "controls", "button", "spinner", "label", "end", "status"];
  static values = {
    url: String,
    scrollRoot: { type: String, default: "window" },
    rootMargin: { type: String, default: "0px 0px 120px" },
    animate: { type: Boolean, default: true },
    manual: { type: Boolean, default: false },
    loadMoreLabel: { type: String, default: "Load more" },
    loadingLabel: { type: String, default: "Loading..." },
    endMessage: { type: String, default: "You're all caught up" },
    errorMessage: { type: String, default: "More items could not be loaded. Try again." },
  };

  connect() {
    this.loading = false;
    this.abortController = null;
    this.buttonFadeFrame = null;
    this.nextAnimationStart = performance.now();
    this.animateItems(Array.from(this.listTarget.children));
    this.resetControls();

    this.observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) this.load();
      },
      {
        root: this.scrollRootValue === "container" ? this.viewportTarget : null,
        rootMargin: this.rootMarginValue,
        threshold: 0.01,
      },
    );

    if (this.hasUrlValue && this.urlValue) {
      if (!this.manualValue) this.observer.observe(this.sentinelTarget);
    } else {
      this.finish();
    }
  }

  disconnect() {
    this.observer?.disconnect();
    this.abortController?.abort();
    this.cancelButtonFade();
  }

  async load() {
    if (this.loading || !this.hasUrlValue || !this.urlValue) return;

    this.loading = true;
    this.observer.unobserve(this.sentinelTarget);
    this.setControlsSticky(true);
    this.controlsTarget.setAttribute("aria-busy", "true");
    this.showButton({ fade: true });
    this.endTarget.hidden = true;
    this.buttonTarget.disabled = true;
    this.spinnerTarget.hidden = false;
    this.labelTarget.textContent = this.loadingLabelValue;
    this.labelTarget.classList.add("animate-pulse");
    const requestController = new AbortController();
    this.abortController = requestController;

    try {
      const response = await fetch(this.urlValue, {
        headers: { Accept: "application/json" },
        credentials: "same-origin",
        signal: requestController.signal,
      });

      if (!response.ok) throw new Error(`Request failed with ${response.status}`);

      const { html, next_url: nextUrl } = await response.json();
      const newItems = this.append(html);

      this.urlValue = nextUrl || "";
      this.statusTarget.textContent = `${newItems.length} more items loaded.`;
      this.dispatch("loaded", { detail: { count: newItems.length, nextUrl: this.urlValue } });

      if (this.urlValue && newItems.length > 0) {
        this.resetControls();
        if (!this.manualValue) this.observer.observe(this.sentinelTarget);
      } else {
        this.finish();
      }
    } catch (error) {
      if (error.name !== "AbortError") this.showError();
    } finally {
      if (this.abortController === requestController) {
        this.loading = false;
        this.abortController = null;
      }
    }
  }

  append(html) {
    const template = document.createElement("template");
    template.innerHTML = html?.trim() || "";

    const items = Array.from(template.content.children);
    this.listTarget.append(template.content);
    this.animateItems(items);

    return items;
  }

  animateItems(items) {
    if (!this.animateValue || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const pendingItems = items.filter((item) => item.dataset.entered !== "true");
    const now = performance.now();
    const firstStart = Math.max(this.nextAnimationStart, now);

    pendingItems.forEach((item, index) => {
      const delay = firstStart + index * 45 - now;
      this.animateItem(item, delay);
    });

    this.nextAnimationStart = firstStart + pendingItems.length * 45;
  }

  animateItem(item, delay) {
    item.dataset.entered = "true";
    item.animate?.(
      [
        { opacity: 0, transform: "translateY(8px) scale(0.99)" },
        { opacity: 1, transform: "translateY(0) scale(1)" },
      ],
      {
        duration: 240,
        delay,
        easing: "cubic-bezier(0.215, 0.61, 0.355, 1)",
        fill: "backwards",
      },
    );
  }

  resetControls() {
    this.setControlsSticky(true);
    this.controlsTarget.removeAttribute("aria-busy");
    this.manualValue ? this.showButton() : this.hideButton();
    this.buttonTarget.disabled = false;
    this.spinnerTarget.hidden = true;
    this.labelTarget.textContent = this.loadMoreLabelValue;
    this.labelTarget.classList.remove("animate-pulse");
    this.endTarget.hidden = true;
  }

  finish() {
    this.observer?.disconnect();
    this.setControlsSticky(false);
    this.controlsTarget.removeAttribute("aria-busy");
    this.hideButton();
    this.labelTarget.classList.remove("animate-pulse");
    this.endTarget.hidden = false;
    this.endTarget.textContent = this.endMessageValue;
    this.statusTarget.textContent = "All items loaded.";
  }

  showError() {
    this.setControlsSticky(true);
    this.controlsTarget.removeAttribute("aria-busy");
    this.showButton();
    this.buttonTarget.disabled = false;
    this.spinnerTarget.hidden = true;
    this.labelTarget.textContent = "Try again";
    this.labelTarget.classList.remove("animate-pulse");
    this.statusTarget.textContent = this.errorMessageValue;
  }

  setControlsSticky(sticky) {
    this.controlsTarget.classList.toggle("sticky", sticky);
  }

  showButton({ fade = false } = {}) {
    this.cancelButtonFade();
    this.buttonTarget.hidden = false;

    if (!fade || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      this.buttonTarget.classList.remove("opacity-0");
      this.buttonTarget.classList.add("opacity-100");
      return;
    }

    this.buttonTarget.classList.remove("opacity-100");
    this.buttonTarget.classList.add("opacity-0");
    this.buttonFadeFrame = requestAnimationFrame(() => {
      this.buttonTarget.classList.remove("opacity-0");
      this.buttonTarget.classList.add("opacity-100");
      this.buttonFadeFrame = null;
    });
  }

  hideButton() {
    this.cancelButtonFade();
    this.buttonTarget.hidden = true;
    this.buttonTarget.classList.remove("opacity-100");
    this.buttonTarget.classList.add("opacity-0");
  }

  cancelButtonFade() {
    if (this.buttonFadeFrame === null) return;

    cancelAnimationFrame(this.buttonFadeFrame);
    this.buttonFadeFrame = null;
  }
}

Examples

Activity Feed

Scroll the feed to load the next Pagy page automatically. The loading pill fades in over 150ms, stays pinned to the viewport while its label pulses, and new rows enter with a short stagger. These animations respect reduced-motion preferences.

Recent activity

Updates from across your workspace

Live
  1. Olivia Martin shipped a new release

    Dashboard filters are now live

    Release
  2. Noah Williams left feedback

    The onboarding flow is looking sharp

    Comment
  3. Mia Thompson uploaded 4 files

    Brand refresh explorations

    Files
  4. Ethan Carter completed a task

    Prepare the Q3 roadmap

    Completed
  5. Sophia Chen invited a teammate

    Jordan joined the Product workspace

    Team
  6. Lucas Bennett created a project

    Mobile experience refresh

    Project

<% items_only = local_assigns.fetch(:items_only, false) %>
<% manual = local_assigns.fetch(:manual, false) %>

<% unless items_only %>
  <section
    class="w-full max-w-xl my-12 overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/10 dark:bg-neutral-900 dark:ring-white/15"
    aria-labelledby="activity-feed-title"
    data-controller="infinite-scroll"
    data-infinite-scroll-url-value="<%= next_url %>"
    data-infinite-scroll-manual-value="<%= manual %>"
    data-infinite-scroll-scroll-root-value="container">
    <header class="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
      <div>
        <h4 id="activity-feed-title" class="font-semibold text-neutral-950 dark:text-white">Recent activity</h4>
        <p class="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">Updates from across your workspace</p>
      </div>
      <span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-600/15 ring-inset dark:bg-emerald-400/10 dark:text-emerald-300 dark:ring-emerald-400/20">
        <span class="size-1.5 rounded-full bg-emerald-500"></span>
        Live
      </span>
    </header>

    <div class="small-scrollbar max-h-[30rem] overflow-y-auto overscroll-contain" data-infinite-scroll-target="viewport">
      <ol class="divide-y divide-neutral-100 px-2 dark:divide-neutral-800/80" role="list" data-infinite-scroll-target="list">
<% end %>

<% items.each do |item| %>
  <li
    id="activity_<%= item[:id] %>"
    class="flex gap-3 rounded-lg px-3 py-4">
    <span class="relative mt-0.5 size-10 shrink-0 overflow-hidden rounded-full bg-neutral-100 ring-1 ring-black/10 dark:bg-neutral-800 dark:ring-white/10">
      <img class="size-full object-cover" src="<%= item[:avatar_url] %>" alt="" loading="lazy">
    </span>
    <div class="min-w-0 flex-1">
      <div class="flex items-start justify-between gap-3">
        <p class="text-sm leading-5 text-neutral-600 dark:text-neutral-300">
          <span class="font-medium text-neutral-950 dark:text-white"><%= item[:name] %></span>
          <%= item[:action] %>
        </p>
        <time class="shrink-0 text-xs leading-5 text-neutral-400 dark:text-neutral-500"><%= item[:time] %></time>
      </div>
      <div class="mt-2 flex items-center gap-2">
        <p class="truncate text-sm text-neutral-500 dark:text-neutral-400"><%= item[:detail] %></p>
        <span class="shrink-0 rounded-md bg-neutral-100 px-1.5 py-0.5 text-[11px] font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300"><%= item[:label] %></span>
      </div>
    </div>
  </li>
<% end %>

<% unless items_only %>
      </ol>

      <div class="h-20 [overflow-anchor:none]" data-infinite-scroll-target="sentinel"></div>

      <div class="pointer-events-none sticky bottom-0 z-10 -mt-20 flex h-20 items-center justify-center px-4 py-3" data-infinite-scroll-target="controls">
        <button
          type="button"
          class="pointer-events-auto inline-flex min-h-9 min-w-32 items-center justify-center gap-2 rounded-full bg-neutral-100/75 px-4 py-2 text-sm font-medium text-neutral-600 opacity-0 shadow-lg shadow-black/10 ring-1 ring-black/10 backdrop-blur-sm backdrop-filter transition-[color,background-color,opacity] duration-150 ease-out motion-reduce:transition-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-wait dark:bg-neutral-800/75 dark:text-neutral-300 dark:ring-white/15 dark:hover:bg-neutral-800 dark:hover:text-white dark:focus-visible:outline-neutral-300"
          data-infinite-scroll-target="button"
          data-action="infinite-scroll#load"
          <% unless manual %>hidden<% end %>>
          <svg hidden data-infinite-scroll-target="spinner" class="size-4 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3"></circle>
            <path class="opacity-80" fill="currentColor" d="M21 12a9 9 0 0 0-9-9v3a6 6 0 0 1 6 6h3Z"></path>
          </svg>
          <span class="motion-reduce:animate-none" data-infinite-scroll-target="label">Load more</span>
        </button>
        <p hidden class="rounded-full bg-neutral-100/75 px-4 py-2 text-sm text-neutral-500 shadow-lg shadow-black/10 ring-1 ring-black/10 backdrop-blur-sm backdrop-filter dark:bg-neutral-800/75 dark:text-neutral-400 dark:ring-white/15" data-infinite-scroll-target="end">You're all caught up</p>
      </div>
    </div>

    <p class="sr-only" aria-live="polite" data-infinite-scroll-target="status"></p>
  </section>
<% end %>
resources :activities, only: :index
class ActivitiesController < ApplicationController
  def index
    activities = Activity.order(created_at: :desc, id: :desc)
    @pagy, @activities = pagy(:keyset, activities, limit: 6)
    @next_page_url = @pagy.next && activities_path(page: @pagy.next)

    respond_to do |format|
      format.html
      format.json do
        render json: {
          html: render_to_string(
            partial: "activities/feed",
            formats: [:html],
            locals: { items: @activities, items_only: true }
          ),
          next_url: @next_page_url,
          count: @activities.length
        }
      end
    end
  end
end

Configuration

Configure Infinite Scroll with Stimulus values when using the plain ERB example, or pass the equivalent arguments to the ViewComponent and shared partial.

Controller Values

All values belong on the element with data-controller="infinite-scroll".

Value Data attribute Type Default Description
url data-infinite-scroll-url-value String "" URL for the next JSON batch. Set it to an empty string or omit it when the collection is complete.
scrollRoot data-infinite-scroll-scroll-root-value String "window" Use "window" for page scrolling or "container" when viewport is an internally scrolling element.
rootMargin data-infinite-scroll-root-margin-value String "0px 0px 120px" IntersectionObserver root margin. Increase the bottom value to start loading earlier.
animate data-infinite-scroll-animate-value Boolean true Animates initial and appended top-level items in DOM order. Reduced-motion preferences always disable the entrance animation.
manual data-infinite-scroll-manual-value Boolean false Disables IntersectionObserver loading and shows the Load more button. The button is hidden while idle in automatic mode.
loadMoreLabel data-infinite-scroll-load-more-label-value String "Load more" Button label used in manual mode.
loadingLabel data-infinite-scroll-loading-label-value String "Loading..." Pulsing label shown with the spinner while a request is active.
endMessage data-infinite-scroll-end-message-value String "You're all caught up" Message shown at the collection's true bottom after the final batch.
errorMessage data-infinite-scroll-error-message-value String "More items could not be loaded. Try again." Message announced by the live region after a failed request.

Targets

Keep these targets when adapting the plain markup. Appended content belongs inside list; each top-level element is treated as one item.

Target Description
viewport The observed scroll container when scrollRoot is container.
list Container that receives each top-level element returned in the response HTML.
sentinel Invisible observer trigger placed after the list. It is separate from the visible loading controls.
controls Sticky floating-controls layer. It stops being sticky when the collection is complete.
button Manual Load more action, loading display, and retry action. Hidden while automatic mode is idle.
spinner Loading spinner shown only while a request is active.
label Button text for manual, loading, and retry states.
end Completion message shown at the collection's true bottom.
status Screen-reader-only aria-live region for loading, completion, and error announcements.

JSON Response

The next-page endpoint must return JSON. The html string should contain item markup only—not another Infinite Scroll wrapper. Every top-level element is appended directly to the list or grid.

{
  "html": "<article>...</article><article>...</article>",
  "next_url": "/products?page=3",
  "count": 2
}

Return null for next_url after the final batch. The controller also finishes safely if the response contains no top-level elements.

Actions and Events

Name Type Description
infinite-scroll#load Action Loads the current URL. Used by the manual and retry button; duplicate requests are ignored while loading.
infinite-scroll:loaded Event Dispatched after a successful append with event.detail.count and event.detail.nextUrl.

Table of contents

Powered by

Get notified when new components come out