Pagination Rails Components

Elegant, Turbo-friendly pagination components that pair Pagy's speed with modern UI styling. Perfect for lists, tables, and data-heavy pages.

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.2.2"

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

3. Add pagination partials

Copy the pagination partials to your Rails application. You can download all partials at once or install them individually.

Download all 5 pagination view partials as a ZIP file.

Download all partials (ZIP)

How to install

After downloading, unzip the file and copy the partials to your app/views/shared/ directory.

Install each partial individually. Click on a partial to expand and see its code, preview, and download option. I recommend adding them to your app/views/shared/ directory.

4. Custom CSS

Add the custom CSS styles for Pagy pagination components. Copy this file to your app/assets/stylesheets/ directory.

.pagy {
  --spacing: 0.15rem;
  --padding: 0.7rem;
  --rounding: 0.5rem;
  --border-width: 1px;
  --font-size: 0.875rem;
  --font-weight: 500;
  --line-height: 1.6;

  /* light (tailwind neutral) */
  --text: #171717; /* neutral-900 */
  --text-hover: #0a0a0a; /* neutral-950 */
  --text-current: #fafafa; /* neutral-50 */
  --background: #fafafa; /* neutral-50 */
  --background-hover: #f5f5f5; /* neutral-100 */
  --background-current: #171717; /* neutral-900 */
  --background-input: #ffffff;

  color: var(--text);
  font-size: var(--font-size);
  line-height: var(--line-height);
  font-weight: var(--font-weight);
  display: flex;
  user-select: none;
}

.pagy > :not([hidden]) ~ :not([hidden]) {
  margin-left: calc(var(--spacing) * (1 - var(--space-reverse, 0)));
  margin-right: calc(var(--spacing) * var(--space-reverse, 0));
}

.rtl .pagy > :not([hidden]) ~ :not([hidden]) {
  --space-reverse: 1;
}

.pagy a:not([role="separator"]) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2rem;
  min-height: 2rem;
  text-decoration: none;
  background-color: var(--background);
  padding: 0.25rem;
  border: var(--border-width) solid #e5e5e5; /* neutral-200 */
  border-radius: var(--rounding);
  color: inherit;
}

.pagy a[href]:hover {
  background-color: var(--background-hover);
  color: var(--text-hover);
}

.pagy a:not([href]) {
  cursor: default;
}

.pagy a[role="link"]:not([aria-current]) {
  opacity: 0.5;
  cursor: not-allowed;
}

.pagy a[aria-current],
.pagy span[aria-current] {
  background-color: var(--background-current);
  color: var(--text-current);
  border-color: var(--background-current);
}

.pagy a[aria-label="Previous"],
.pagy a[aria-label="Next"] {
  position: relative;
  font-size: 0;
  color: var(--text);
}

.pagy a[aria-label="Previous"]::before,
.pagy a[aria-label="Next"]::before {
  content: "";
  display: block;
  width: 1rem;
  height: 1rem;
  margin: auto;
  background-size: 0.75rem 0.75rem;
  background-repeat: no-repeat;
  background-position: center;
}

.pagy a[aria-label="Previous"]::before {
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' stroke='currentColor'%3E%3Cpolyline points='7.75 1.75 3.5 6 7.75 10.25'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}

.pagy a[aria-label="Next"]::before {
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' stroke='currentColor'%3E%3Cpolyline points='4.25 10.25 8.5 6 4.25 1.75'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}

.pagy [role="separator"] {
  background: transparent;
  color: #a3a3a3; /* neutral-400 */
  padding: 0.25rem 0.4rem;
  border: none;
  box-shadow: none;
}

.pagy label {
  white-space: nowrap;
  display: inline-block;
  border: var(--border-width) solid #e5e5e5;
  border-radius: var(--rounding);
  background-color: var(--background);
  padding: 0.2rem 0.5rem;
}

.pagy label input {
  all: unset;
  border: 1px solid #e5e5e5;
  border-radius: calc(var(--rounding) / 2) !important;
  background-color: var(--background-input);
  padding: 0.25rem 0.5rem;
  font-size: 0.85rem;
  color: var(--text);
}

.pagy-compact .pagy {
  --spacing: 0.1rem;
  --padding: 0.55rem;
  --rounding: 0.35rem;
}

.dark .pagy {
  /* dark (tailwind neutral) */
  --text: #f5f5f5; /* neutral-100 */
  --text-hover: #ffffff;
  --text-current: #0a0a0a; /* neutral-950 */
  --background: #262626; /* neutral-800 */
  --background-hover: #404040; /* neutral-700 */
  --background-current: #f5f5f5; /* neutral-100 */
  --background-input: #262626; /* neutral-800 */
}

.dark .pagy a:not([role="separator"]) {
  border-color: #ffffff1a; /* white/10 */
  background-color: var(--background);
}

.dark .pagy a:not([role="separator"]):hover {
  border-color: #ffffff1a; /* white/10 */
  background-color: var(--background-hover);
  color: var(--text-hover);
}

.dark .pagy a[aria-current]:hover {
  background-color: var(--background-current);
  color: var(--text-current);
}

.dark .pagy a[aria-current] {
  border-color: var(--background-current);
  background-color: var(--background-current);
}

.dark .pagy a[aria-label="Previous"]::before {
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.2' stroke='%23f5f5f5'%3E%3Cpolyline points='7.75 1.75 3.5 6 7.75 10.25'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}

.dark .pagy a[aria-label="Next"]::before {
  background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.2' stroke='%23f5f5f5'%3E%3Cpolyline points='4.25 10.25 8.5 6 4.25 1.75'%3E%3C/polyline%3E%3C/g%3E%3C/svg%3E");
}

.dark .pagy [role="separator"] {
  color: #a3a3a3; /* neutral-400 */
}

.dark .pagy label {
  border-color: #262626;
  background-color: var(--background);
}

.dark .pagy label input {
  border-color: #262626;
  background-color: var(--background-input);
  color: var(--text);
}

/* Loading state for Turbo Frame pagination */
turbo-frame[aria-busy="true"] .pagy {
  position: relative;
  pointer-events: none;
  opacity: 0.6;
  transition: opacity 0.15s ease;
}

/* Loading indicator with overlay */
turbo-frame[aria-busy="true"][data-pagy-loading-indicator] {
  position: relative;
  cursor: wait;
  pointer-events: none;
}

/* Semi-transparent overlay */
turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::before {
  content: "";
  position: absolute;
  inset: 0;
  background-color: rgba(255, 255, 255, 0.75);
  backdrop-filter: blur(2px);
  z-index: 10;
  opacity: 0;
  animation: pagy-fade-in 0.15s ease forwards;
}

.dark turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::before {
  background-color: rgba(23, 23, 23, 0.75);
}

/* Spinning loader */
turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 24px;
  height: 24px;
  border: 2.5px solid #e5e5e5;
  border-top-color: #171717;
  border-radius: 50%;
  opacity: 0;
  animation: pagy-spin 0.6s linear infinite, pagy-fade-in 0.15s ease forwards;
  z-index: 11;
  pointer-events: none;
}

.dark turbo-frame[aria-busy="true"][data-pagy-loading-indicator]::after {
  border-color: #404040;
  border-top-color: #f5f5f5;
}

@keyframes pagy-spin {
  to {
    transform: translate(-50%, -50%) rotate(360deg);
  }
}

@keyframes pagy-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Mobile responsive styles */
@media (max-width: 640px) {
  .pagy {
    --spacing: 0.1rem;
    --padding: 0.3rem;
    --font-size: 0.75rem;
  }

  .pagy a:not([role="separator"]) {
    min-width: 1.75rem;
    min-height: 1.75rem;
    padding: 0.1rem;
  }

  .pagy a[role="separator"] {
    padding: 0.15rem 0.2rem;
    font-size: 0.75rem;
  }

  .pagy label {
    padding: 0.1rem 0.3rem;
  }

  .pagy label input {
    padding: 0.15rem 0.3rem;
    font-size: 0.75rem;
  }
}

Examples

Note that a sleep(1) is added to the controller to simulate a slow response from the server so you can see the loading states.

Basic pagination

Clean pagination with numbered page buttons and item count display.

DE

devise

Flexible authentication solution

412M
SI

sidekiq

Simple, efficient background processing

385M
PA

pagy

The best pagination ruby gem

47M
PU

pundit

Minimal authorization through OO design

156M
KA

kaminari

A Scope & Engine based paginator

289M
RA

ransack

Object-based searching

178M
DR

draper

View Models for Rails

98M
SC

scenic

Versioned database views for Rails

45M
ST

stimulus-rails

A modest JavaScript framework

23M
TU

turbo-rails

The speed of a SPA

34M

Showing 1–10 of 84

<% frame_id = local_assigns.fetch(:frame_id, "pagy-basic") %>

<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
  <div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
    <% (gems || []).each do |gem| %>
      <div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
        <div class="flex items-center gap-3">
          <div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
            <%= gem[:name][0..1].upcase %>
          </div>
          <div>
            <p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
            <p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
          </div>
        </div>
        <div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
      </div>
    <% end %>
  </div>

  <div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
    <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
      <%= render "shared/pagy_info", pagy: pagy %>

      <%= render "shared/pagy_nav", pagy: pagy, frame_id: frame_id, preserve_params: request.query_parameters %>
    </div>
  </div>
</turbo-frame>
# Basic pagination with numbered page buttons
class GemsController < ApplicationController
  def index
    # @pagy: pagination object with metadata (page, pages, count, etc.)
    # @gems: the paginated subset of records for the current page
    # limit: number of items per page (default: 20)
    @pagy, @gems = pagy(Gem.all, limit: 10)
  end
end

Pagination with jump to page

Adds a jump-to-page input for quick navigation to specific pages.

DE

devise

Flexible authentication solution

412M
SI

sidekiq

Simple, efficient background processing

385M
PA

pagy

The best pagination ruby gem

47M
PU

pundit

Minimal authorization through OO design

156M
KA

kaminari

A Scope & Engine based paginator

289M
RA

ransack

Object-based searching

178M
DR

draper

View Models for Rails

98M
SC

scenic

Versioned database views for Rails

45M
ST

stimulus-rails

A modest JavaScript framework

23M
TU

turbo-rails

The speed of a SPA

34M

Showing 1–10 of 84

<% frame_id = local_assigns.fetch(:frame_id, "pagy-jump-to-page") %>

<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
  <div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
    <% (gems || []).each do |gem| %>
      <div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
        <div class="flex items-center gap-3">
          <div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
            <%= gem[:name][0..1].upcase %>
          </div>
          <div>
            <p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
            <p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
          </div>
        </div>
        <div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
      </div>
    <% end %>
  </div>

  <div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
    <div class="flex flex-col items-center gap-3">
      <%= render "shared/pagy_info", pagy: pagy %>
      <div class="flex flex-wrap items-center justify-center gap-3">
        <%= render "shared/pagy_nav",
            pagy: pagy,
            frame_id: frame_id,
            preserve_params: request.query_parameters %>
        <%= render "shared/pagy_page_form",
            pagy: pagy,
            frame_id: frame_id,
            preserve_params: request.query_parameters %>
      </div>
    </div>
  </div>
</turbo-frame>
# Pagination with jump to page input
class GemsController < ApplicationController
  def index
    # @pagy: pagination object with metadata (page, pages, count, etc.)
    # @gems: the paginated subset of records for the current page
    # The jump-to-page input works automatically with the standard pagy object
    @pagy, @gems = pagy(Gem.all, limit: 10)
  end
end

Pagination with row selection

Adds a row selection input for quick navigation to specific pages.

DE

devise

Flexible authentication solution

412M
SI

sidekiq

Simple, efficient background processing

385M
PA

pagy

The best pagination ruby gem

47M
PU

pundit

Minimal authorization through OO design

156M
KA

kaminari

A Scope & Engine based paginator

289M
RA

ransack

Object-based searching

178M
DR

draper

View Models for Rails

98M
SC

scenic

Versioned database views for Rails

45M
ST

stimulus-rails

A modest JavaScript framework

23M
TU

turbo-rails

The speed of a SPA

34M

Showing 1–10 of 84

<% frame_id = local_assigns.fetch(:frame_id, "pagy-row-selection") %>

<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
  <div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
    <% (gems || []).each do |gem| %>
      <div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
        <div class="flex items-center gap-3">
          <div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
            <%= gem[:name][0..1].upcase %>
          </div>
          <div>
            <p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
            <p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
          </div>
        </div>
        <div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
      </div>
    <% end %>
  </div>

  <div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
    <div class="flex flex-col gap-3 lg:flex-row items-center justify-between">
      <%= render "shared/pagy_info", pagy: pagy %>
      <%= render "shared/pagy_nav",
          pagy: pagy,
          frame_id: frame_id,
          preserve_params: request.query_parameters %>
      <%= render "shared/pagy_limit_form",
          pagy: pagy,
          frame_id: frame_id,
          preserve_params: request.query_parameters,
          limit_options: [10, 25, 50] %>
    </div>
  </div>
</turbo-frame>
# Pagination with customizable items per page
class GemsController < ApplicationController
  def index
    # Only allow 10, 25, or 50 items per page; default to 10 if invalid
    # This prevents users from requesting arbitrary limits (e.g., 999,999)
    limit = ([params[:limit].to_i] & [10, 25, 50]).first || 10

    # @pagy: pagination object with metadata (page, pages, count, etc.)
    # @gems: the paginated subset of records for the current page
    @pagy, @gems = pagy(Gem.all, limit: limit)
  end
end

Previous / Next only

Minimal navigation with just previous and next buttons.

DE

devise

Flexible authentication solution

412M
SI

sidekiq

Simple, efficient background processing

385M
PA

pagy

The best pagination ruby gem

47M
PU

pundit

Minimal authorization through OO design

156M
KA

kaminari

A Scope & Engine based paginator

289M
RA

ransack

Object-based searching

178M
DR

draper

View Models for Rails

98M
SC

scenic

Versioned database views for Rails

45M
ST

stimulus-rails

A modest JavaScript framework

23M
TU

turbo-rails

The speed of a SPA

34M
Page 1 of 9

Showing 1–10 of 84

<
<% frame_id = local_assigns.fetch(:frame_id, "pagy-prev-next") %>

<turbo-frame id="<%= frame_id %>" data-pagy-loading-indicator class="overflow-hidden w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
  <div class="max-h-[300px] overflow-y-auto divide-y divide-black/5 dark:divide-white/5">
    <% (gems || []).each do |gem| %>
      <div class="flex items-center justify-between gap-4 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50">
        <div class="flex items-center gap-3">
          <div class="grid size-8 sm:size-10 place-items-center rounded-lg bg-gradient-to-br from-red-500 to-red-600 text-xs sm:text-sm font-bold text-white shrink-0">
            <%= gem[:name][0..1].upcase %>
          </div>
          <div>
            <p class="text-sm font-semibold text-neutral-900 dark:text-white"><%= gem[:name] %></p>
            <p class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:description] %></p>
          </div>
        </div>
        <div class="text-sm text-neutral-500 dark:text-neutral-300"><%= gem[:downloads] %></div>
      </div>
    <% end %>
  </div>

  <div class="border-t border-black/10 px-4 py-3 dark:border-white/10 relative z-20">
    <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
      <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
        <span class="text-sm text-neutral-500 dark:text-neutral-400">
          Page <span class="font-medium text-neutral-900 dark:text-white"><%= pagy.page %></span> of <span class="font-medium text-neutral-900 dark:text-white"><%= pagy.pages %></span>
        </span>
        <div class="border-l border-black/10 h-4 dark:border-white/10"></div>
        <%= render "shared/pagy_info", pagy: pagy %>
      </div>
      <%= render "shared/pagy_prev_next", pagy: pagy, frame_id: frame_id, preserve_params: request.query_parameters %>
    </div>
  </div>
</turbo-frame>
# Simple previous/next pagination
class GemsController < ApplicationController
  def index
    # @pagy: pagination object with metadata (page, pages, count, etc.)
    # @gems: the paginated subset of records for the current page
    # Minimal setup: prev/next buttons work with standard pagy object
    @pagy, @gems = pagy(Gem.all, limit: 10)
  end
end

Configuration

The pagination component is powered by Pagy, a fast and lightweight pagination library. Below are the most common configuration options and usage patterns.

Basic usage

In your controller, use the pagy method to paginate your collection:

# app/controllers/users_controller.rb
def index
  # Returns two values:
  # @pagy: pagination metadata (page, pages, count, from, to, etc.)
  # @users: the paginated subset of records for the current page
  @pagy, @users = pagy(User.all, limit: 25)
end

Understanding pagy options

The pagy method accepts several useful options:

@pagy, @records = pagy(
  collection,              # Any collection (ActiveRecord, Array, etc.)
  limit: 25,              # Items per page (default: 20)
  page: params[:page],    # Current page (automatically read from params)
  page_key: "page",       # URL param name for page (useful for multiple tables)
  limit_key: "limit",     # URL param name for limit (useful for multiple tables)
  overflow: :last_page    # Redirect invalid pages to last page (prevents errors)
)

Turbo-friendly view pattern

Wrap your list and pagination in a Turbo Frame so the nav updates without full-page reloads:

<turbo-frame id="articles">
  <%= render @articles %>
  <%= render "shared/pagination", pagy: @pagy, turbo_frame: "articles" %>
</turbo-frame>

Composable partials

Use the shared partials individually to place the info, nav, page input, or limit select anywhere in your layout. Pass preserve_params to maintain query parameters across page changes:

<%# shared/pagy_info + shared/pagy_nav + shared/pagy_page_form + shared/pagy_limit_form %>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
  <%= render "shared/pagy_info", pagy: pagy %>
  <div class="flex flex-wrap items-center gap-3">
    <%= render "shared/pagy_nav", pagy: pagy, frame_id: "orders-frame", preserve_params: request.query_parameters %>
    <%= render "shared/pagy_page_form", pagy: pagy, frame_id: "orders-frame", preserve_params: request.query_parameters %>
    <%= render "shared/pagy_limit_form", pagy: pagy, frame_id: "orders-frame", limit_options: [10, 25, 50], preserve_params: request.query_parameters %>
  </div>
</div>

Configuration options

Option Description Example
limit Number of items per page limit: 25
page Current page number (automatically set from params) page: params[:page]
overflow Handle out-of-bounds page requests (e.g., bookmarked invalid page) overflow: :last_page
params Custom parameters to include in pagination links params: { query: 'search' }
size Number of page links to show (format: [left, center, right]) size: [1, 4, 1]

Overflow handling

Add overflow: :last_page to gracefully handle out-of-bounds page requests. For example, if a user bookmarks page 10 but you only have 3 pages, they'll be redirected to page 3 instead of seeing an error:

@pagy, @records = pagy(collection, limit: 15, overflow: :last_page)

Preserving query parameters

When using search, filters, or sorting alongside pagination, pass preserve_params to maintain these parameters across page changes:

<%= render "shared/pagy_nav",
     pagy: @pagy,
     frame_id: "results-frame",
     preserve_params: request.query_parameters %>

Partial options

Partial Available Options
pagy_nav pagy (required), frame_id (optional), preserve_params (optional)
pagy_info pagy (required)
pagy_page_form pagy (required), frame_id (optional), preserve_params (optional)
pagy_limit_form pagy (required), frame_id (optional), limit_options (optional, default: [10, 25, 50]), preserve_params (optional)
pagy_prev_next pagy (required), frame_id (optional), preserve_params (optional)

Multiple tables on one page

You can have multiple paginated tables on the same page by using unique parameter names for each table. This keeps each table's pagination independent without conflicts.

Controller setup:

# app/controllers/dashboard_controller.rb
def index
  # First table - users
  @pagy_users, @users = pagy(
    User.all,
    page_key: :users_page,
    limit_key: :users_limit,
    limit: 10
  )

  # Second table - orders
  @pagy_orders, @orders = pagy(
    Order.all,
    page_key: :orders_page,
    limit_key: :orders_limit,
    limit: 15
  )
end

View setup:

<%# Users Table %>
<turbo-frame id="users-table">
  <div class="mb-8">
    <h2>Users</h2>
    <%# ...table content... %>

    <div class="flex items-center justify-between">
      <%= render "shared/pagy_info", pagy: @pagy_users %>
      <div class="flex gap-3">
        <%= render "shared/pagy_nav",
              pagy: @pagy_users,
              frame_id: "users-table",
              page_key: "users_page",
              limit_key: "users_limit",
              preserve_params: request.query_parameters %>
      </div>
    </div>
  </div>
</turbo-frame>

<%# Orders Table %>
<turbo-frame id="orders-table">
  <div>
    <h2>Orders</h2>
    <%# ...table content... %>

    <div class="flex items-center justify-between">
      <%= render "shared/pagy_info", pagy: @pagy_orders %>
      <div class="flex gap-3">
        <%= render "shared/pagy_nav",
              pagy: @pagy_orders,
              frame_id: "orders-table",
              page_key: "orders_page",
              limit_key: "orders_limit",
              preserve_params: request.query_parameters %>
      </div>
    </div>
  </div>
</turbo-frame>

The URL will include both tables' parameters: /dashboard?users_page=2&orders_page=3. Each table updates independently via Turbo, and preserve_params ensures both tables maintain each other's pagination state.

Advanced features

  • Turbo Frame Support: Seamless integration with Hotwire Turbo for instant page updates
  • Query Parameter Preservation: Maintain search, filter, and sort parameters across pages
  • Composable Partials: Mix and match navigation, info, page jump, and limit controls
  • Multiple Tables: Support for multiple independent paginated tables on one page
  • Overflow Protection: Gracefully handle invalid page requests
  • Lightweight & Fast: Pagy is one of the fastest pagination libraries available

Learn more

These components cover the most common pagination patterns. For advanced features like infinite scroll, countless pagination, calendar pagination, and more, check out the comprehensive Pagy documentation.

Table of contents

Get notified when new components come out