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, and new rows enter with a short stagger. Both transitions 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

Table of contents

Powered by

Get notified when new components come out