Pagination · Patterns
Controlled page, link patch, server events, and client-only updates.
Controlled
Current page: 1
<.pagination
class="pagination"
count={18}
page={@page}
page_size={4}
controlled
on_page_change="pagination_controlled_changed"
>
<:prev><.heroicon name="hero-chevron-left" /></:prev>
<:next><.heroicon name="hero-chevron-right" /></:next>
<:ellipsis><.heroicon name="hero-ellipsis-horizontal" /></:ellipsis>
</.pagination>
<p class="text-ink-muted text-sm">Current page: {@page}</p>
def mount(_params, _session, socket) do
{:ok, assign(socket, :page, 1)}
end
def handle_event("pagination_controlled_changed", %{"page" => page}, socket) do
{:noreply, assign(socket, :page, page)}
end
Link patch
-
Getting started with Corex
Tokens, components, and zero custom CSS.
-
Pagination with LiveView patch
Link mode updates the URL; the server loads the next slice.
-
Design tokens in practice
Spacing, ink, and layer colors stay consistent across themes.
-
BEM modifiers on buttons
button button--accent button--lg — no new class names.
<ul class="flex flex-col gap-space w-full max-w-md">
<li :for={post <- @posts} class="flex flex-col gap-space-xs p-space rounded-md bg-layer border border-border">
<h3>{post.title}</h3>
<p class="text-ink-muted text-sm">{post.excerpt}</p>
</li>
</ul>
<.pagination
class="pagination"
count={18}
page={@page}
page_size={4}
controlled={:all}
type={:link}
to="/pagination/patterns"
redirect={:patch}
>
<:prev><.heroicon name="hero-chevron-left" /></:prev>
<:next><.heroicon name="hero-chevron-right" /></:next>
<:ellipsis><.heroicon name="hero-ellipsis-horizontal" /></:ellipsis>
</.pagination>
defmodule MyAppWeb.PaginationPatternsLive do
use MyAppWeb, :live_view
alias MyApp.Blog
@page_size 4
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page, 1)
|> assign(:posts, Blog.slice(1, @page_size))}
end
@impl true
def handle_params(params, _uri, socket) do
page = param_to_page(params["page"])
posts = Blog.slice(page, @page_size)
{:noreply,
socket
|> assign(:page, page)
|> assign(:posts, posts)}
end
defp param_to_page(nil), do: 1
defp param_to_page(raw) when is_binary(raw) do
case Integer.parse(raw) do
{n, _} when n > 0 -> n
_ -> 1
end
end
defp param_to_page(n) when is_integer(n) and n > 0, do: n
defp param_to_page(_), do: 1
end
live "/pagination/patterns", MyAppWeb.PaginationPatternsLive
# Example URL: /en/pagination/patterns?page=2&page_size=4
defmodule MyApp.Blog do
@posts [
%{title: "Getting started with Corex", excerpt: "Tokens, components, and zero custom CSS."},
%{title: "Pagination with LiveView patch", excerpt: "Link mode updates the URL; the server loads the next slice."},
%{title: "Design tokens in practice", excerpt: "Spacing, ink, and layer colors stay consistent across themes."},
%{title: "BEM modifiers on buttons", excerpt: "button button--accent button--lg — no new class names."},
%{title: "Zag.js under the hood", excerpt: "Accessible behavior with Phoenix-friendly SSR."},
%{title: "Async lists feel faster", excerpt: "assign_async plus a short delay shows real loading states."},
%{title: "Playground controls", excerpt: "Tune page size and sibling windows without leaving the demo."},
%{title: "Ellipsis in pagination", excerpt: "Large page counts collapse into prev · 1 … 5 · 10 · next."},
%{title: "RTL support", excerpt: "Flip direction in the toolbar and pagination follows."},
%{title: "Controlled vs link mode", excerpt: "Events for in-memory state; patch for shareable URLs."},
%{title: "Short blog excerpts", excerpt: "Demo content only — no database required."},
%{title: "Skeleton placeholders", excerpt: "Dotted squares while the next page loads."},
%{title: "Patch navigation", excerpt: "data-phx-link on every page control anchor."},
%{title: "Page size in the query", excerpt: "?page=2&page_size=5 keeps bookmarkable state."},
%{title: "Corex in Phoenix 1.8", excerpt: "LiveView, HEEx, and hooks wired for production apps."},
%{title: "One more post", excerpt: "Enough items to paginate comfortably."},
%{title: "Almost done", excerpt: "Three pages at five per page."},
%{title: "Last demo post", excerpt: "End of the sample blog list."}
]
def posts, do: @posts
def count, do: length(@posts)
def slice(page, page_size) do
offset = max(page - 1, 0) * page_size
Enum.slice(@posts, offset, page_size)
end
end
Trigger server
-
Getting started with Corex
Tokens, components, and zero custom CSS.
-
Pagination with LiveView patch
Link mode updates the URL; the server loads the next slice.
-
Design tokens in practice
Spacing, ink, and layer colors stay consistent across themes.
-
BEM modifiers on buttons
button button--accent button--lg — no new class names.
<ul class="flex flex-col gap-space w-full max-w-md">
<li :for={post <- @posts} class="flex flex-col gap-space-xs p-space rounded-md bg-layer border border-border">
<h3>{post.title}</h3>
<p class="text-ink-muted text-sm">{post.excerpt}</p>
</li>
</ul>
<.pagination
class="pagination"
count={18}
page={@page}
page_size={4}
controlled
on_page_change="patterns_server_page"
>
<:prev><.heroicon name="hero-chevron-left" /></:prev>
<:next><.heroicon name="hero-chevron-right" /></:next>
<:ellipsis><.heroicon name="hero-ellipsis-horizontal" /></:ellipsis>
</.pagination>
defmodule MyAppWeb.PaginationPatternsLive do
use MyAppWeb, :live_view
alias MyApp.Blog
@page_size 4
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page, 1)
|> assign(:posts, Blog.slice(1, @page_size))}
end
@impl true
def handle_event("patterns_server_page", %{"page" => page}, socket) do
page = parse_page(page)
{:noreply,
socket
|> assign(:page, page)
|> assign(:posts, Blog.slice(page, @page_size))}
end
defp parse_page(page) when is_integer(page) and page > 0, do: page
defp parse_page(page) when is_binary(page) do
case Integer.parse(page) do
{n, _} when n > 0 -> n
_ -> 1
end
end
defp parse_page(_), do: 1
end
defmodule MyApp.Blog do
@posts [
%{title: "Getting started with Corex", excerpt: "Tokens, components, and zero custom CSS."},
%{title: "Pagination with LiveView patch", excerpt: "Link mode updates the URL; the server loads the next slice."},
%{title: "Design tokens in practice", excerpt: "Spacing, ink, and layer colors stay consistent across themes."},
%{title: "BEM modifiers on buttons", excerpt: "button button--accent button--lg — no new class names."},
%{title: "Zag.js under the hood", excerpt: "Accessible behavior with Phoenix-friendly SSR."},
%{title: "Async lists feel faster", excerpt: "assign_async plus a short delay shows real loading states."},
%{title: "Playground controls", excerpt: "Tune page size and sibling windows without leaving the demo."},
%{title: "Ellipsis in pagination", excerpt: "Large page counts collapse into prev · 1 … 5 · 10 · next."},
%{title: "RTL support", excerpt: "Flip direction in the toolbar and pagination follows."},
%{title: "Controlled vs link mode", excerpt: "Events for in-memory state; patch for shareable URLs."},
%{title: "Short blog excerpts", excerpt: "Demo content only — no database required."},
%{title: "Skeleton placeholders", excerpt: "Dotted squares while the next page loads."},
%{title: "Patch navigation", excerpt: "data-phx-link on every page control anchor."},
%{title: "Page size in the query", excerpt: "?page=2&page_size=5 keeps bookmarkable state."},
%{title: "Corex in Phoenix 1.8", excerpt: "LiveView, HEEx, and hooks wired for production apps."},
%{title: "One more post", excerpt: "Enough items to paginate comfortably."},
%{title: "Almost done", excerpt: "Three pages at five per page."},
%{title: "Last demo post", excerpt: "End of the sample blog list."}
]
def posts, do: @posts
def count, do: length(@posts)
def slice(page, page_size) do
offset = max(page - 1, 0) * page_size
Enum.slice(@posts, offset, page_size)
end
end
Trigger client
-
Getting started with Corex
Tokens, components, and zero custom CSS.
-
Pagination with LiveView patch
Link mode updates the URL; the server loads the next slice.
-
Design tokens in practice
Spacing, ink, and layer colors stay consistent across themes.
-
BEM modifiers on buttons
button button--accent button--lg — no new class names.
<div
id="pagination-patterns-client-wrap"
phx-hook=".PaginationPatternsClient"
phx-update="ignore"
data-pagination-id="pagination-patterns-client"
data-pages={@pages_json}
>
<script :type={Phoenix.LiveView.ColocatedHook} name=".PaginationPatternsClient">
export default {
mounted() {
const pages = JSON.parse(this.el.dataset.pages);
const list = document.getElementById("pagination-patterns-client-list");
const pagination = document.getElementById(this.el.dataset.paginationId);
const render = (page) => {
list.replaceChildren(
...(pages[page - 1] ?? []).map((post) => {
const li = document.createElement("li");
li.className =
"flex flex-col gap-space-xs p-space rounded-md bg-layer border border-border";
const title = document.createElement("h3");
title.textContent = post.title;
const excerpt = document.createElement("p");
excerpt.className = "text-ink-muted text-sm";
excerpt.textContent = post.excerpt;
li.append(title, excerpt);
return li;
})
);
};
pagination?.addEventListener("pagination-page-changed", (e) => {
render(e.detail.page);
});
},
};
</script>
<ul id="pagination-patterns-client-list" class="flex flex-col gap-space w-full max-w-md">
<li
:for={post <- @posts}
class="flex flex-col gap-space-xs p-space rounded-md bg-layer border border-border"
>
<h3>{post.title}</h3>
<p class="text-ink-muted text-sm">{post.excerpt}</p>
</li>
</ul>
</div>
<.pagination
id="pagination-patterns-client"
class="pagination"
count={18}
page_size={4}
on_page_change_client="pagination-page-changed"
>
<:prev><.heroicon name="hero-chevron-left" /></:prev>
<:next><.heroicon name="hero-chevron-right" /></:next>
<:ellipsis><.heroicon name="hero-ellipsis-horizontal" /></:ellipsis>
</.pagination>
defmodule MyAppWeb.PaginationPatternsLive do
use MyAppWeb, :live_view
alias MyApp.Blog
@page_size 4
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:posts, Blog.slice(1, @page_size))
|> assign(:pages_json, pages_json(@page_size))}
end
defp pages_json(page_size) do
total_pages =
Blog.count()
|> then(fn count ->
if count == 0, do: 0, else: div(count + page_size - 1, page_size)
end)
1..max(total_pages, 1)
|> Enum.map(fn page ->
Blog.slice(page, page_size)
|> Enum.map(&Map.take(&1, [:title, :excerpt]))
end)
|> Jason.encode!()
end
end
defmodule MyApp.Blog do
@posts [
%{title: "Getting started with Corex", excerpt: "Tokens, components, and zero custom CSS."},
%{title: "Pagination with LiveView patch", excerpt: "Link mode updates the URL; the server loads the next slice."},
%{title: "Design tokens in practice", excerpt: "Spacing, ink, and layer colors stay consistent across themes."},
%{title: "BEM modifiers on buttons", excerpt: "button button--accent button--lg — no new class names."},
%{title: "Zag.js under the hood", excerpt: "Accessible behavior with Phoenix-friendly SSR."},
%{title: "Async lists feel faster", excerpt: "assign_async plus a short delay shows real loading states."},
%{title: "Playground controls", excerpt: "Tune page size and sibling windows without leaving the demo."},
%{title: "Ellipsis in pagination", excerpt: "Large page counts collapse into prev · 1 … 5 · 10 · next."},
%{title: "RTL support", excerpt: "Flip direction in the toolbar and pagination follows."},
%{title: "Controlled vs link mode", excerpt: "Events for in-memory state; patch for shareable URLs."},
%{title: "Short blog excerpts", excerpt: "Demo content only — no database required."},
%{title: "Skeleton placeholders", excerpt: "Dotted squares while the next page loads."},
%{title: "Patch navigation", excerpt: "data-phx-link on every page control anchor."},
%{title: "Page size in the query", excerpt: "?page=2&page_size=5 keeps bookmarkable state."},
%{title: "Corex in Phoenix 1.8", excerpt: "LiveView, HEEx, and hooks wired for production apps."},
%{title: "One more post", excerpt: "Enough items to paginate comfortably."},
%{title: "Almost done", excerpt: "Three pages at five per page."},
%{title: "Last demo post", excerpt: "End of the sample blog list."}
]
def posts, do: @posts
def count, do: length(@posts)
def slice(page, page_size) do
offset = max(page - 1, 0) * page_size
Enum.slice(@posts, offset, page_size)
end
end