# Infinite Scroll
Load server-rendered records in small batches when the user reaches the end of a page or scrollable container. The component uses Stimulus for observation and rendering, Pagy for pagination, and Tailwind CSS for presentation.
## Features
- Automatic loading with `IntersectionObserver`
- Optional manual “Load more” mode
- Window or container-based scrolling
- Pagy-backed JSON pagination
- Arbitrary server-rendered item markup
- Configurable labels, messages, and observer margin
- Reduced-motion-safe item and loading transitions
- Accessible live-region updates and loading state
- Reusable ERB partial and ViewComponent implementations
## Requirements
- Rails 7+
- Tailwind CSS 4+
- Stimulus
- Pagy 43+
Add Pagy to your Gemfile:
```ruby
gem "pagy", "~> 43.5.6"
```
Include Pagy in the controller where the feed is loaded:
```ruby
class ApplicationController < ActionController::Base
include Pagy::Method
end
```
Install `infinite_scroll_controller.js` in `app/javascript/controllers`. Stimulus Rails and Importmap will register it automatically when controllers are eager-loaded.
## Server Response Contract
The URL passed to the component must return JSON with server-rendered item HTML and the next page URL:
```json
{
"html": "<article>...</article>",
"next_url": "/activities?page=3",
"count": 6
}
```
Every top-level element in `html` is appended directly to the items container. Return `null` for `next_url` when the collection is complete.
For production feeds, paginate a uniquely ordered Active Record relation with Pagy's keyset paginator:
```ruby
class ActivitiesController < ApplicationController
def index
activities = Activity.order(created_at: :desc, id: :desc)
@pagy, @activities = pagy(:keyset, activities, limit: 6)
next_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/items",
formats: [:html],
locals: { activities: @activities }
),
next_url: next_url,
count: @activities.length
}
end
end
end
end
```
## ERB Partial
Render arbitrary direct child elements inside the shared partial:
```erb
<%= render "shared/components/infinite_scroll/infinite_scroll",
next_url: @next_page_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 %>
<% @activities.each do |activity| %>
<li id="activity_<%= activity.id %>" class="p-4">
<%= activity.description %>
</li>
<% end %>
<% end %>
```
Use `manual: true` to show a button instead of observing the sentinel automatically.
## ViewComponent
Use the same content model through `InfiniteScroll::Component`:
```erb
<%= render InfiniteScroll::Component.new(
next_url: @next_page_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 %>
<% @activities.each do |activity| %>
<li id="activity_<%= activity.id %>" class="p-4">
<%= activity.description %>
</li>
<% end %>
<% end %>
```
## Options
| Option | Default | Description |
| --- | --- | --- |
| `next_url` | required | URL for the next JSON batch. Use `nil` when complete. |
| `scroll_root` | `window` | Observe against the window or the internal `container`. |
| `root_margin` | `0px 0px 120px` | IntersectionObserver margin used to preload before the end. |
| `animate` | `true` | Animate initial and appended top-level items. |
| `manual` | `false` | Disable automatic observation and show “Load more.” |
| `items_tag` | `div` | Semantic items wrapper: `div`, `ul`, or `ol`. |
| `classes` | `nil` | Additional outer-wrapper classes. |
| `viewport_classes` | `nil` | Scrolling, sizing, and layout classes for the viewport. |
| `items_classes` | `nil` | Layout classes for the items container. |
| `sentinel_classes` | `nil` | Additional classes for the floating controls wrapper. |
| `load_more_label` | `Load more` | Label used by the manual button. |
| `loading_label` | `Loading...` | Label shown while requesting a page. |
| `end_message` | `You're all caught up` | Completed-state message. |
| `error_message` | `More items could not be loaded. Try again.` | Accessible failure message. |
| `html_options` | `{}` | Additional attributes for the outer wrapper. |
## Behavior Notes
- The controller prevents overlapping requests and aborts an active fetch when disconnected.
- Automatic mode stops observing the sentinel while a request is active.
- The loading pill fades in over 150ms. Item entrances and the loading fade are disabled when reduced motion is preferred.
- Failed requests keep the current content and expose a retry button.
- The end message replaces the loading control when `next_url` is empty.
- A stable unique order is required to avoid duplicate or missing records between pages.
---
## AI Instructions
### Choose An Implementation
- **Vanilla / plain ERB:** Use the activity-feed example when you want a ready-made visual treatment and direct markup control.
- **ERB template partial:** Use when you want a reusable render call with arbitrary server-rendered children.
- **ViewComponent:** Use when you prefer a Ruby initializer, encapsulated defaults, and component tests.
### Quick Reference
- **Vanilla example:** `app/views/components/infinite_scroll/_1_activity_feed.html.erb`
- **ERB partial:** `app/views/shared/components/infinite_scroll/_infinite_scroll.html.erb`
- **ERB usage:** `render "shared/components/infinite_scroll/infinite_scroll"`
- **ViewComponent:** `render InfiniteScroll::Component.new(...)`
- **Stimulus controller:** `app/javascript/controllers/infinite_scroll_controller.js`
### Implementation Checklist
- Install Pagy and include `Pagy::Method` in the relevant Rails controller.
- Install and register `infinite_scroll_controller.js`.
- Return JSON containing `html`, `next_url`, and optionally `count`.
- Ensure each top-level response element is a complete item suitable for direct append.
- Use a unique, deterministic database order; prefer Pagy keyset pagination for changing feeds.
- Set `scroll_root` to `container` only when `viewport_classes` creates a constrained overflow area.
- Preserve the sentinel, controls, status live region, Stimulus targets, and data values.
- Keep `next_url` empty when no records remain.
### Common Mistakes
- Do not return a full layout or Turbo Frame response from the JSON endpoint.
- Do not wrap all returned items in an extra container; only top-level elements are appended.
- Do not use an unstable sort such as `created_at` without a unique tiebreaker.
- Do not remove the `aria-live` status region or `aria-busy` updates.
- Do not mix ERB partial locals with ViewComponent initializer arguments.
- Do not remove the initial opacity and transition classes from the loading button; the controller relies on them for the fade.