Pagination navigation component for paginating large data sets.
Built with pure HEEx and phx-click / phx-value-page for LiveView
integration. No JavaScript hooks required.
Sub-components
| Component | Element | Purpose |
|---|---|---|
pagination/1 | <nav> | Landmark wrapper with role="navigation" |
pagination_content/1 | <ul> | Flex row container for all page items |
pagination_item/1 | <li> | Individual item wrapper |
pagination_link/1 | <button> | Page number button with active-state highlight |
pagination_previous/1 | <button> | Previous-page button (disabled on page 1) |
pagination_next/1 | <button> | Next-page button (disabled on last page) |
pagination_ellipsis/1 | <span> | … gap indicator for skipped page ranges |
Basic LiveView integration
The simplest pattern: every page is rendered as a button. For small sets (≤ 7 pages) render all pages directly without ellipsis.
defmodule MyAppWeb.PostsLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, page: 1, total_pages: 10, posts: load_posts(1))}
end
def handle_event("go", %{"page" => page}, socket) do
page = String.to_integer(page)
{:noreply, assign(socket, page: page, posts: load_posts(page))}
end
endTemplate:
<.pagination>
<.pagination_content>
<.pagination_item>
<.pagination_previous current_page={@page} total_pages={@total_pages} on_change="go" />
</.pagination_item>
<%= for p <- 1..@total_pages do %>
<.pagination_item>
<.pagination_link page={p} current_page={@page} on_change="go">
{p}
</.pagination_link>
</.pagination_item>
<% end %>
<.pagination_item>
<.pagination_next current_page={@page} total_pages={@total_pages} on_change="go" />
</.pagination_item>
</.pagination_content>
</.pagination>Pagination with ellipsis (large page counts)
For large page counts, show first, last, current, and neighbours, with
pagination_ellipsis/1 for gaps. Compute the visible page range in the
LiveView to keep the template clean:
defp page_window(current, total) do
neighbors = MapSet.new([1, total, current, current - 1, current + 1])
Enum.filter(1..total, &MapSet.member?(neighbors, &1))
endThen in the template:
<.pagination_content>
<.pagination_item>
<.pagination_previous current_page={@page} total_pages={@total} on_change="go" />
</.pagination_item>
<%= for {p, idx} <- Enum.with_index(page_window(@page, @total)) do %>
<%!-- Insert ellipsis when there's a gap between consecutive visible pages --%>
<%= if idx > 0 and p - Enum.at(page_window(@page, @total), idx - 1) > 1 do %>
<.pagination_item><.pagination_ellipsis /></.pagination_item>
<% end %>
<.pagination_item>
<.pagination_link page={p} current_page={@page} on_change="go">{p}</.pagination_link>
</.pagination_item>
<% end %>
<.pagination_item>
<.pagination_next current_page={@page} total_pages={@total} on_change="go" />
</.pagination_item>
</.pagination_content>URL-based pagination with handle_params
For shareable URLs use phx-patch or update params in handle_event:
def handle_event("go", %{"page" => page}, socket) do
{:noreply, push_patch(socket, to: ~p"/posts?page=#{page}")}
end
def handle_params(%{"page" => page}, _uri, socket) do
page = String.to_integer(page)
{:noreply, assign(socket, page: page, posts: load_posts(page))}
endAccessibility
- The
<nav>carriesrole="navigation"andaria-label="pagination". - The active page button carries
aria-current="page". - Previous/next buttons carry descriptive
aria-labelattributes. - Disabled prev/next buttons set the HTML
disabledattribute so keyboard navigation and screen readers skip them.
Summary
Functions
Renders cursor-based pagination with Previous and Next buttons.
Renders a centered "Load more" button for append-style infinite scroll.
Renders the <nav role="navigation" aria-label="pagination"> wrapper.
Renders the <ul> flex container for pagination items.
Renders a … ellipsis to indicate a gap in the page number sequence.
Renders a <li> wrapper for a single pagination control.
Renders a page number button.
Renders a next-page button with a chevron-right icon.
Renders a previous-page button with a chevron-left icon.
Functions
Renders cursor-based pagination with Previous and Next buttons.
Unlike offset pagination, cursor pagination uses opaque cursor tokens rather than page numbers. Useful for infinite-scroll APIs and large result sets where offset pagination is expensive.
Attributes
has_previous_page(:boolean) (required) - Whether a previous page exists.has_next_page(:boolean) (required) - Whether a next page exists.on_previous(:string) - phx-click event for previous. Defaults to"previous-page".on_next(:string) - phx-click event for next. Defaults to"next-page".previous_cursor(:any) - Cursor value sent with previous event. Defaults tonil.next_cursor(:any) - Cursor value sent with next event. Defaults tonil.class(:string) - Defaults tonil.- Global attributes are accepted.
Renders a centered "Load more" button for append-style infinite scroll.
When loading is true, the button shows a spinner icon and the
loading_label text and is non-interactive.
Attributes
loading(:boolean) - Shows spinner and loading_label when true. Defaults tofalse.on_click(:string) (required) - phx-click event to load the next batch.label(:string) - Defaults to"Load more".loading_label(:string) - Defaults to"Loading...".disabled(:boolean) - Defaults tofalse.class(:string) - Defaults tonil.- Global attributes are accepted.
Renders the <nav role="navigation" aria-label="pagination"> wrapper.
This is the outermost container. Always place a pagination_content/1
inside it.
Attributes
class(:string) - Additional CSS classes applied to the<nav>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<nav>element.
Slots
inner_block(required) - Should contain a singlepagination_content/1.
Renders the <ul> flex container for pagination items.
All pagination_item/1 children are rendered in a horizontal flex row
with a small gap between items.
Attributes
class(:string) - Additional CSS classes applied to the<ul>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<ul>element.
Slots
inner_block(required) - Should containpagination_item/1children.
Renders a … ellipsis to indicate a gap in the page number sequence.
Use between pagination_link/1 items when collapsing a range of pages.
The ellipsis is aria-hidden="true" because it carries no actionable
information for screen readers — the surrounding visible page numbers
provide sufficient context.
Example
<%!-- Renders: 1 … 4 5 6 … 20 --%>
<.pagination_item><.pagination_link page={1} ...>1</.pagination_link></.pagination_item>
<.pagination_item><.pagination_ellipsis /></.pagination_item>
<.pagination_item><.pagination_link page={4} ...>4</.pagination_link></.pagination_item>
...Attributes
class(:string) - Additional CSS classes applied to the<span>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<span>element.
Renders a <li> wrapper for a single pagination control.
Wrap each pagination_link/1, pagination_previous/1, pagination_next/1,
and pagination_ellipsis/1 in a pagination_item/1.
Attributes
class(:string) - Additional CSS classes applied to the<li>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<li>element.
Slots
inner_block(required) - Apagination_link/1,pagination_previous/1,pagination_next/1, orpagination_ellipsis/1.
Renders a page number button.
The active page (page == current_page) is highlighted with
bg-primary text-primary-foreground and receives aria-current="page".
All other pages use a transparent background with a hover accent.
Fires a phx-click event with phx-value-page={page} when clicked.
The LiveView receives the page as a string, so cast with
String.to_integer/1 in handle_event.
Example
<.pagination_link page={3} current_page={@page} on_change="navigate">
3
</.pagination_link>Attributes
page(:integer) (required) - The page number this button represents (1-based).current_page(:integer) (required) - The currently active page number. Controls active styling andaria-current.on_change(:string) - Thephx-clickevent name sent to the LiveView. Receivespageas a string value viaphx-value-page. Defaults to"page-changed".class(:string) - Additional CSS classes applied to the<button>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<button>element.
Slots
inner_block(required) - Page label — typically just the page number.
Renders a next-page button with a chevron-right icon.
When current_page == total_pages, the button is visually and functionally
disabled:
- The HTML
disabledattribute is set. pointer-events-none opacity-50is applied.phx-clickis not attached.
Example
<.pagination_next current_page={@page} total_pages={@total} on_change="go" />Attributes
current_page(:integer) (required) - The currently active page. When equal tototal_pages, the button is disabled.total_pages(:integer) (required) - Total number of pages. Used to disable the button on the last page.on_change(:string) - Thephx-clickevent name sent to the LiveView. Defaults to"page-changed".class(:string) - Additional CSS classes applied to the<button>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<button>element.
Renders a previous-page button with a chevron-left icon.
When current_page == 1, the button is visually and functionally disabled:
- The HTML
disabledattribute is set (removes from tab order). pointer-events-none opacity-50is applied.phx-clickis not attached (no event fires).
Example
<.pagination_previous current_page={@page} total_pages={@total} on_change="go" />Attributes
current_page(:integer) (required) - The currently active page. When1, the button is disabled.total_pages(:integer) (required) - Total number of pages. Used to determine the page value sent on click.on_change(:string) - Thephx-clickevent name sent to the LiveView. Defaults to"page-changed".class(:string) - Additional CSS classes applied to the<button>element. Defaults tonil.- Global attributes are accepted. HTML attributes forwarded to the
<button>element.