# Pagination Component
Page navigation for paginated lists and tables with Turbo Frame support for seamless SPA-like navigation.
## Features
- **Pagy integration** - Works with the Pagy pagination gem
- Three display variants (full numbered, compact, minimal)
- Three size options (sm, md, lg)
- "Jump to page" form for quick navigation
- Rows per page selector
- Turbo Frame support for SPA-like navigation
- Query parameter preservation across page changes
- Responsive design with dark mode support
- Accessible with proper ARIA labels
## Implementation Options
| Format | Location | Best For |
| ------ | -------- | -------- |
| **Plain ERB** | `app/views/components/pagination/` | Full control, copy-paste |
| **Shared Partial** | `app/views/shared/components/pagination/` | Reusable partial with locals |
| **ViewComponent** | `app/components/pagination/` | Ruby-based, testable, object-oriented |
---
## Pagy Setup
This component requires the Pagy gem. First, ensure Pagy is configured in your initializer:
```ruby
# config/initializers/pagy.rb
Pagy.options[:overflow] = :last_page
Pagy.options[:limit] = 20
```
In your controller:
```ruby
class ItemsController < ApplicationController
include Pagy::Backend
def index
@pagy, @items = pagy(Item.all)
end
end
```
In your views, include the Pagy frontend helper:
```ruby
# app/helpers/application_helper.rb
module ApplicationHelper
include Pagy::Frontend
end
```
---
## Shared Partials
### Basic Usage
```erb
<%= render "shared/components/pagination/pagination",
pagy: @pagy %>
```
### With Turbo Frame
```erb
<turbo-frame id="items-frame">
<!-- Your list content -->
<div class="border-t pt-4">
<%= render "shared/components/pagination/pagination",
pagy: @pagy,
frame_id: "items-frame",
preserve_params: request.query_parameters %>
</div>
</turbo-frame>
```
### With All Options
```erb
<%= render "shared/components/pagination/pagination",
pagy: @pagy,
variant: "full",
size: "md",
frame_id: "table-frame",
show_info: true,
show_page_form: true,
show_limit_form: true,
limit_options: [10, 25, 50, 100],
preserve_params: request.query_parameters,
classes: "mt-4" %>
```
### Options
| Local | Type | Default | Description |
| ----- | ---- | ------- | ----------- |
| `pagy` | Pagy | required | Pagy pagination object |
| `variant` | String | `"full"` | `"full"`, `"compact"`, `"minimal"` |
| `size` | String | `"md"` | `"sm"`, `"md"`, `"lg"` |
| `frame_id` | String | `nil` | Turbo Frame ID for SPA navigation |
| `show_info` | Boolean | `true` | Show "Showing X-Y of Z" text |
| `show_page_form` | Boolean | `false` | Show jump to page form (full variant only) |
| `show_limit_form` | Boolean | `false` | Show rows per page selector |
| `limit_options` | Array | `[10, 25, 50]` | Options for limit selector |
| `preserve_params` | Hash | `{}` | Query params to preserve |
| `request_path` | String | `request.path` | Custom path for forms |
| `classes` | String | `nil` | Additional wrapper classes |
---
## ViewComponent
### Basic Usage
```erb
<%= render Pagination::Component.new(
pagy: @pagy
) %>
```
### With Turbo Frame
```erb
<%= render Pagination::Component.new(
pagy: @pagy,
frame_id: "items-frame",
preserve_params: request.query_parameters
) %>
```
### With All Options
```erb
<%= render Pagination::Component.new(
pagy: @pagy,
variant: :full,
size: :md,
frame_id: "table-frame",
show_info: true,
show_page_form: true,
show_limit_form: true,
limit_options: [10, 25, 50, 100],
preserve_params: request.query_parameters,
classes: "mt-4"
) %>
```
### Component Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| `pagy` | Pagy | required | Pagy pagination object |
| `variant` | Symbol | `:full` | `:full`, `:compact`, `:minimal` |
| `size` | Symbol | `:md` | `:sm`, `:md`, `:lg` |
| `frame_id` | String | `nil` | Turbo Frame ID for SPA navigation |
| `show_info` | Boolean | `true` | Show "Showing X-Y of Z" text |
| `show_page_form` | Boolean | `false` | Show jump to page form |
| `show_limit_form` | Boolean | `false` | Show rows per page selector |
| `limit_options` | Array | `[10, 25, 50]` | Options for limit selector |
| `preserve_params` | Hash | `{}` | Query params to preserve |
| `request_path` | String | `request.path` | Custom path for forms |
| `classes` | String | `nil` | Additional wrapper classes |
---
## Variants
### Full (Numbered Pages)
Shows all page numbers with previous/next buttons. Best for desktop and when users need to jump to specific pages.
```erb
<%= render Pagination::Component.new(
pagy: @pagy,
variant: :full
) %>
```
### Compact
Shows previous/next buttons with page count info (e.g., "Page 2 of 10"). Good balance between information and space.
```erb
<%= render Pagination::Component.new(
pagy: @pagy,
variant: :compact
) %>
```
### Minimal
Shows only previous/next buttons. Best for mobile or when space is limited.
```erb
<%= render Pagination::Component.new(
pagy: @pagy,
variant: :minimal,
show_info: false
) %>
```
---
## Complete Table Example
Here's a full example of pagination within a data table using Turbo Frames:
```erb
<turbo-frame id="users-table" data-pagy-loading-indicator>
<div class="overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
<div class="max-h-[400px] overflow-y-auto">
<table class="w-full">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-neutral-700 dark:text-neutral-300">Name</th>
<th class="px-4 py-3 text-left text-sm font-medium text-neutral-700 dark:text-neutral-300">Email</th>
<th class="px-4 py-3 text-left text-sm font-medium text-neutral-700 dark:text-neutral-300">Role</th>
</tr>
</thead>
<tbody class="divide-y divide-black/5 dark:divide-white/5">
<% @users.each do |user| %>
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
<td class="px-4 py-3 text-sm text-neutral-900 dark:text-white"><%= user.name %></td>
<td class="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400"><%= user.email %></td>
<td class="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400"><%= user.role %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="border-t border-black/10 px-4 py-3 dark:border-white/10">
<%= render Pagination::Component.new(
pagy: @pagy,
frame_id: "users-table",
show_limit_form: true,
preserve_params: request.query_parameters
) %>
</div>
</div>
</turbo-frame>
```
---
## Loading Indicator
Add a loading indicator for Turbo Frame navigation using the `data-pagy-loading-indicator` attribute:
```erb
<turbo-frame id="results" data-pagy-loading-indicator>
<!-- Content -->
</turbo-frame>
```
Then add CSS to show a loading state:
```css
[data-pagy-loading-indicator][aria-busy="true"] {
opacity: 0.5;
pointer-events: none;
}
```
---
## Pagy Styling
The component relies on Pagy's built-in CSS classes. Add the following to your CSS to style the pagination buttons:
```css
/* Pagy pagination button styles */
.pagy a,
.pagy span.current,
.pagy span[aria-disabled="true"] {
@apply inline-flex items-center justify-center rounded-md border border-black/10 bg-white px-3 py-1.5 text-sm font-medium transition-colors dark:border-white/10 dark:bg-neutral-900;
}
.pagy a {
@apply text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800;
}
.pagy span.current {
@apply bg-neutral-900 text-white dark:bg-white dark:text-neutral-900;
}
.pagy span[aria-disabled="true"] {
@apply cursor-not-allowed text-neutral-400 dark:text-neutral-600;
}
/* Gap indicator */
.pagy span.gap {
@apply px-2 text-neutral-400 dark:text-neutral-600;
}
```
---
## Accessibility
- Uses `<nav>` element with `aria-label="Pagination"`
- Proper `aria-current="page"` on current page
- `aria-disabled="true"` on disabled prev/next buttons
- `aria-label` attributes on navigation buttons
- Form labels for input fields
---
## Troubleshooting
**Pages not updating in Turbo Frame:** Ensure `frame_id` matches the `<turbo-frame>` ID and links include `data-turbo-frame`.
**Query params lost on pagination:** Pass `preserve_params: request.query_parameters` to maintain filters and sorts.
**Limit form resets to page 1:** This is intentional behavior to avoid "no results" when changing rows per page on a high page number.
**Styles not applying:** Make sure Pagy CSS is included and classes match your Tailwind configuration.
---
## AI Instructions
### Choose An Implementation
- **Vanilla / plain ERB**: Use when you want full markup control or need to adapt the example directly inside a page.
- **shared partial**: Use when you want a reusable partial with locals and a consistent render call across views.
- **ViewComponent**: Use when you want a Ruby API, slots, stronger encapsulation, or repeated composition in multiple places.
### Quick Reference
- **Vanilla examples**: `app/views/components/pagination/`
- **Shared partial files**: `app/views/shared/components/pagination/`
- **shared partial**: `render "shared/components/pagination/pagination"`
- **ViewComponent**: `render Pagination::Component.new(...)`
- **ViewComponent files**: `app/components/pagination/`
### Implementation Checklist
- Pick one implementation path first, then stay consistent within that example.
- Use only documented locals, initializer arguments, variants, and slot names.
- Copy the base example before adding app-specific styling or behavior.
- If the component is interactive, keep the documented Stimulus controller, targets, values, and ARIA wiring intact.
- Keep variant names, enum values, and option formats exactly as documented.
### Common Patterns
- **Vanilla / plain ERB**: Best for one-off pages, direct markup edits, and quick adaptations of the shipped examples.
- **shared partial**: Best for reusable Rails view partials driven by locals or collections.
- **ViewComponent**: Best for reusable component implementations that benefit from Ruby-side defaults, slots, or testable composition.
### Common Mistakes
- Do not mix shared partial locals with ViewComponent initializer arguments in the same example.
- Do not invent undocumented variants, sizes, slot names, or helper methods.
- Do not remove required wrapper elements, IDs, or data attributes when copying the component.
- Do not swap the documented render path for a guessed partial or component name.