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