shshadcn-htmx

Components

Pagination

A <nav> landmark with aria-label; active page carries aria-current="page". Previous/Next pre-labelled so AT users hear the action, not the glyph.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/pagination.json

2. Use it

components/ui/pagination.tsx
import { Pagination, PaginationItem, PaginationLink,
  PaginationPrevious, PaginationNext, PaginationEllipsis } from "@/components/ui/pagination"

<Pagination ariaLabel="Articles">
  <PaginationItem><PaginationPrevious href="/articles?page=1" /></PaginationItem>
  <PaginationItem><PaginationLink href="/articles?page=1">1</PaginationLink></PaginationItem>
  <PaginationItem><PaginationLink active>2</PaginationLink></PaginationItem>
  <PaginationItem><PaginationLink href="/articles?page=3">3</PaginationLink></PaginationItem>
  <PaginationItem><PaginationEllipsis /></PaginationItem>
  <PaginationItem><PaginationNext href="/articles?page=3" /></PaginationItem>
</Pagination>
Or copy the source manually
components/ui/pagination.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Pagination — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A standard navigation strip with Previous / 1 / 2 / 3 / … / Next.
// We render a real <nav> landmark with aria-label so AT users can jump
// to it directly. The active page carries aria-current="page" per WAI:
//   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-current/
//
// Server-driven model: each page link is a real <a> with href OR an htmx
// button that swaps a target. The component itself doesn't manage state
// — give it the page numbers + an href/builder + the active page.

type PaginationProps = PropsWithChildren<{
  // Accessible label for the navigation landmark.
  ariaLabel?: string
  class?: ClassValue
}>

export function Pagination(props: PaginationProps) {
  const { ariaLabel = "Pagination", class: className, children } = props
  return (
    <nav
      data-slot="pagination"
      aria-label={ariaLabel}
      class={cn("mx-auto flex w-full justify-center", className)}
    >
      <ul class="flex flex-row items-center gap-1">{children}</ul>
    </nav>
  )
}

// Each page (or ellipsis) goes inside an <li>.
type PaginationItemProps = PropsWithChildren<{ class?: ClassValue }>
export function PaginationItem(props: PaginationItemProps) {
  return <li class={cn(props.class)}>{props.children}</li>
}

// A single page link. `active` adds aria-current="page" + visual emphasis.
type PaginationLinkProps = PropsWithChildren<{
  href?: string
  active?: boolean
  // HTML link type forwarded to the <a>. Pagination prev/next default this to
  // "prev"/"next" — the normative sequence link types:
  //   repos/whatwg-html/source (link types next/prev)
  rel?: string
  disabled?: boolean
  class?: ClassValue
  // htmx attrs ride along.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>
export function PaginationLink(props: PaginationLinkProps) {
  const { href, active, rel, disabled, class: className, children, ...rest } = props
  const Tag: any = href ? "a" : "button"
  return (
    <Tag
      href={href}
      rel={href ? rel : undefined}
      // aria-disabled only exposes the state; per the aria-disabled spec it does
      // NOT suppress activation or remove focus. On the <button> branch use the
      // native disabled attribute so a keyboard user can't fire the hx-* attrs:
      //   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-disabled/index.md
      disabled={href ? undefined : disabled ? true : undefined}
      type={href ? undefined : "button"}
      data-slot="pagination-link"
      aria-current={active ? "page" : undefined}
      class={cn(
        "inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors",
        "hover:bg-accent hover:text-accent-foreground",
        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
        active && "bg-primary text-primary-foreground hover:bg-primary/90",
        className,
      )}
      {...rest}
    >
      {children}
    </Tag>
  )
}

// Previous / Next chrome — same as PaginationLink but with built-in
// aria-label so screen readers don't say "<" or ">".
type PaginationNavProps = PropsWithChildren<{
  href?: string
  disabled?: boolean
  // rel defaults to "prev"/"next" (the WHATWG sequence link types) but is
  // overridable. Widened from hx-* only so anchor attrs can be forwarded.
  rel?: string
  class?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function PaginationPrevious(props: PaginationNavProps) {
  const { href, disabled, rel = "prev", class: className, children, ...rest } = props
  return (
    <PaginationLink
      href={disabled ? undefined : href}
      rel={rel}
      disabled={disabled}
      class={cn("gap-1 pl-2.5", disabled && "pointer-events-none opacity-50", className)}
      data-slot="pagination-prev"
      aria-label="Previous page"
      aria-disabled={disabled ? "true" : undefined}
      {...rest}
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
        <polyline points="15 18 9 12 15 6" />
      </svg>
      {children ?? <span>Previous</span>}
    </PaginationLink>
  )
}

export function PaginationNext(props: PaginationNavProps) {
  const { href, disabled, rel = "next", class: className, children, ...rest } = props
  return (
    <PaginationLink
      href={disabled ? undefined : href}
      rel={rel}
      disabled={disabled}
      class={cn("gap-1 pr-2.5", disabled && "pointer-events-none opacity-50", className)}
      data-slot="pagination-next"
      aria-label="Next page"
      aria-disabled={disabled ? "true" : undefined}
      {...rest}
    >
      {children ?? <span>Next</span>}
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
        <polyline points="9 18 15 12 9 6" />
      </svg>
    </PaginationLink>
  )
}

// Decorative ellipsis between page ranges. aria-hidden so AT skips it
// (the page numbers carry the meaning).
export function PaginationEllipsis(props: { class?: ClassValue }) {
  return (
    <span
      data-slot="pagination-ellipsis"
      aria-hidden="true"
      class={cn("flex h-9 w-9 items-center justify-center text-muted-foreground", props.class)}
    >

    </span>
  )
}

1. Save the file

Copy pagination.html into templates/components/.

2. Use it

templates/components/pagination.html
{% from "components/pagination.html" import pagination_open, pagination_close,
   pagination_prev, pagination_next, pagination_page, pagination_ellipsis %}

{{ pagination_open(aria_label="Articles") }}
  {{ pagination_prev(href="/articles?page=1") }}
  {{ pagination_page(1, href="/articles?page=1") }}
  {{ pagination_page(2, active=true) }}
  {{ pagination_page(3, href="/articles?page=3") }}
  {{ pagination_ellipsis() }}
  {{ pagination_next(href="/articles?page=3") }}
{{ pagination_close() }}
View source
templates/components/pagination.html
{# Pagination macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/pagination.tsx.

   Usage:
     {% from "components/pagination.html" import pagination_open, pagination_close,
        pagination_prev, pagination_next, pagination_page, pagination_ellipsis %}

     {{ pagination_open(aria_label="Articles") }}
       {{ pagination_prev(href="/articles?page=1") }}
       {{ pagination_page(1, href="/articles?page=1") }}
       {{ pagination_page(2, active=true) }}
       {{ pagination_page(3, href="/articles?page=3") }}
       {{ pagination_ellipsis() }}
       {{ pagination_next(href="/articles?page=3") }}
     {{ pagination_close() }} #}

{% macro pagination_open(aria_label="Pagination", extra_class="") %}
<nav data-slot="pagination" aria-label="{{ aria_label }}"
     class="mx-auto flex w-full justify-center {{ extra_class }}">
  <ul class="flex flex-row items-center gap-1">
{% endmacro %}

{% macro pagination_close() %}
  </ul>
</nav>
{% endmacro %}

{# rel = WHATWG sequence link type (prev/next), emitted on the <a> branch only.
   disabled = native disabled attr on the <button> branch: per the aria-disabled
   spec, aria-disabled alone does not suppress keyboard activation, so a disabled
   prev/next button gets the real disabled attr too (drops it from tab order and
   blocks the hx-* it carries). #}
{% macro _link(label, href=none, active=false, class_="", aria_label=none, aria_disabled=false, rel=none, disabled=false, slot="pagination-link", **attrs) %}
{%- set base -%}
inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none
{%- endset -%}
{%- set on -%}{% if active %}bg-primary text-primary-foreground hover:bg-primary/90{% endif %}{%- endset -%}
{% if href %}
<a href="{{ href }}" data-slot="{{ slot }}" {% if active %}aria-current="page"{% endif %}
   {% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
   {% if aria_disabled %}aria-disabled="true"{% endif %}
   {% if rel %}rel="{{ rel }}"{% endif %}
   class="{{ base }} {{ on }} {{ class_ }}"
   {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label|safe }}</a>
{% else %}
<button type="button" data-slot="{{ slot }}" {% if active %}aria-current="page"{% endif %}
   {% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
   {% if aria_disabled %}aria-disabled="true"{% endif %}
   {% if disabled %}disabled{% endif %}
   class="{{ base }} {{ on }} {{ class_ }}"
   {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label|safe }}</button>
{% endif %}
{% endmacro %}

{% macro pagination_page(n, href=none, active=false, **attrs) %}
<li>{{ _link(n|string, href=href, active=active, **attrs) }}</li>
{% endmacro %}

{% macro pagination_prev(href=none, disabled=false, rel="prev", **attrs) %}
<li>{{ _link('<span aria-hidden="true">‹</span> Previous',
              href=none if disabled else href,
              class_="gap-1 pl-2.5" + (" pointer-events-none opacity-50" if disabled else ""),
              aria_label="Previous page", aria_disabled=disabled, disabled=disabled, rel=rel,
              slot="pagination-prev", **attrs) }}</li>
{% endmacro %}

{% macro pagination_next(href=none, disabled=false, rel="next", **attrs) %}
<li>{{ _link('Next <span aria-hidden="true">›</span>',
              href=none if disabled else href,
              class_="gap-1 pr-2.5" + (" pointer-events-none opacity-50" if disabled else ""),
              aria_label="Next page", aria_disabled=disabled, disabled=disabled, rel=rel,
              slot="pagination-next", **attrs) }}</li>
{% endmacro %}

{% macro pagination_ellipsis() %}
<li><span data-slot="pagination-ellipsis" aria-hidden="true" class="flex h-9 w-9 items-center justify-center text-muted-foreground">…</span></li>
{% endmacro %}

1. Save the file

Add pagination.tmpl alongside button.tmpl.

2. Use it

templates/components/pagination.tmpl
{{template "pagination" (dict "AriaLabel" "Articles" "Body" (htmlSafe `
  {{template "pagination_prev" (dict "Href" "/articles?page=1")}}
  {{template "pagination_page" (dict "N" 1 "Href" "/articles?page=1")}}
  {{template "pagination_page" (dict "N" 2 "Active" true)}}
  {{template "pagination_ellipsis" (dict)}}
  {{template "pagination_next" (dict "Href" "/articles?page=3")}}`))}}
View source
templates/components/pagination.tmpl
{{/* Pagination templates — shadcn-htmx, htmx v4 + Tailwind v4. */}}

{{define "pagination"}}
<nav data-slot="pagination" aria-label="{{or .AriaLabel "Pagination"}}"
     class="mx-auto flex w-full justify-center">
  <ul class="flex flex-row items-center gap-1">{{.Body}}</ul>
</nav>
{{end}}

{{define "pagination_page"}}
{{- $base := "inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" -}}
{{- $on := "" -}}{{- if .Active -}}{{- $on = "bg-primary text-primary-foreground hover:bg-primary/90" -}}{{- end -}}
<li><a href="{{.Href}}" data-slot="pagination-link" {{if .Active}}aria-current="page"{{end}} class="{{$base}} {{$on}}">{{.N}}</a></li>
{{end}}

{{/* Disabled prev/next render as <button disabled> not <a>: aria-disabled alone
     does not suppress keyboard activation (aria-disabled spec), and the native
     disabled attr is invalid on <a>. rel="prev"/"next" are the WHATWG sequence
     link types, emitted on the enabled <a>. Override .Rel to change them. */}}
{{define "pagination_prev"}}
{{- $base := "inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" -}}
{{- $rel := or .Rel "prev" -}}
{{if .Disabled}}<li><button type="button" data-slot="pagination-prev" aria-label="Previous page" aria-disabled="true" disabled class="{{$base}} gap-1 pl-2.5 pointer-events-none opacity-50">‹ Previous</button></li>{{else}}<li><a href="{{.Href}}" data-slot="pagination-prev" aria-label="Previous page" rel="{{$rel}}" class="{{$base}} gap-1 pl-2.5">‹ Previous</a></li>{{end}}
{{end}}

{{define "pagination_next"}}
{{- $base := "inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" -}}
{{- $rel := or .Rel "next" -}}
{{if .Disabled}}<li><button type="button" data-slot="pagination-next" aria-label="Next page" aria-disabled="true" disabled class="{{$base}} gap-1 pr-2.5 pointer-events-none opacity-50">Next ›</button></li>{{else}}<li><a href="{{.Href}}" data-slot="pagination-next" aria-label="Next page" rel="{{$rel}}" class="{{$base}} gap-1 pr-2.5">Next ›</a></li>{{end}}
{{end}}

{{define "pagination_ellipsis"}}
<li><span data-slot="pagination-ellipsis" aria-hidden="true" class="flex h-9 w-9 items-center justify-center text-muted-foreground">…</span></li>
{{end}}

1. Save the file

Drop pagination.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/pagination.ex
<.pagination aria-label="Articles">
  <.pagination_prev href={~p"/articles?page=1"} />
  <.pagination_page n={1} href={~p"/articles?page=1"} />
  <.pagination_page n={2} active />
  <.pagination_page n={3} href={~p"/articles?page=3"} />
  <.pagination_ellipsis />
  <.pagination_next href={~p"/articles?page=3"} />
</.pagination>
View source
lib/my_app_web/components/pagination.ex
defmodule ShadcnHtmx.Components.Pagination do
  @moduledoc """
  Pagination — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  A `<nav>` landmark with `aria-label`. Active page carries
  `aria-current="page"`. Previous/Next have built-in aria-labels
  so AT users hear "Previous page" instead of "<".

  ## Examples

      <.pagination aria-label="Articles">
        <.pagination_prev href={~p"/articles?page=1"} />
        <.pagination_page n={1} href={~p"/articles?page=1"} />
        <.pagination_page n={2} active />
        <.pagination_page n={3} href={~p"/articles?page=3"} />
        <.pagination_ellipsis />
        <.pagination_next href={~p"/articles?page=3"} />
      </.pagination>
  """

  use Phoenix.Component

  attr :"aria-label", :string, default: "Pagination"
  attr :class, :string, default: nil
  slot :inner_block, required: true

  def pagination(assigns) do
    ~H"""
    <nav data-slot="pagination" aria-label={assigns[:"aria-label"]}
         class={["mx-auto flex w-full justify-center", @class]}>
      <ul class="flex flex-row items-center gap-1">{render_slot(@inner_block)}</ul>
    </nav>
    """
  end

  @base "inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors " <>
          "hover:bg-accent hover:text-accent-foreground " <>
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"

  attr :n, :integer, required: true
  attr :href, :string, default: nil
  attr :active, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global

  def pagination_page(assigns) do
    on = if assigns.active, do: "bg-primary text-primary-foreground hover:bg-primary/90", else: ""
    assigns = assign(assigns, on: on, base: @base)

    ~H"""
    <li>
      <a
        href={@href}
        data-slot="pagination-link"
        aria-current={if @active, do: "page"}
        class={[@base, @on, @class]}
        {@rest}
      >
        {@n}
      </a>
    </li>
    """
  end

  attr :href, :string, default: nil
  attr :disabled, :boolean, default: false
  # WHATWG sequence link type, emitted on the enabled <a>. Overridable.
  attr :rel, :string, default: "prev"
  attr :class, :string, default: nil
  attr :rest, :global

  def pagination_prev(assigns) do
    assigns = assign(assigns, base: @base)

    # Disabled renders a native <button disabled>, not an <a>: per the
    # aria-disabled spec, aria-disabled alone does not suppress keyboard
    # activation, and the native disabled attr is invalid on <a>.
    ~H"""
    <li>
      <button
        :if={@disabled}
        type="button"
        data-slot="pagination-prev"
        aria-label="Previous page"
        aria-disabled="true"
        disabled
        class={[@base, "gap-1 pl-2.5 pointer-events-none opacity-50", @class]}
        {@rest}
      >
        ‹ Previous
      </button>
      <a
        :if={!@disabled}
        href={@href}
        data-slot="pagination-prev"
        aria-label="Previous page"
        rel={@rel}
        class={[@base, "gap-1 pl-2.5", @class]}
        {@rest}
      >
        ‹ Previous
      </a>
    </li>
    """
  end

  attr :href, :string, default: nil
  attr :disabled, :boolean, default: false
  attr :rel, :string, default: "next"
  attr :class, :string, default: nil
  attr :rest, :global

  def pagination_next(assigns) do
    assigns = assign(assigns, base: @base)

    ~H"""
    <li>
      <button
        :if={@disabled}
        type="button"
        data-slot="pagination-next"
        aria-label="Next page"
        aria-disabled="true"
        disabled
        class={[@base, "gap-1 pr-2.5 pointer-events-none opacity-50", @class]}
        {@rest}
      >
        Next ›
      </button>
      <a
        :if={!@disabled}
        href={@href}
        data-slot="pagination-next"
        aria-label="Next page"
        rel={@rel}
        class={[@base, "gap-1 pr-2.5", @class]}
        {@rest}
      >
        Next ›
      </a>
    </li>
    """
  end

  def pagination_ellipsis(assigns) do
    ~H"""
    <li>
      <span data-slot="pagination-ellipsis" aria-hidden="true"
            class="flex h-9 w-9 items-center justify-center text-muted-foreground">…</span>
    </li>
    """
  end
end

1. Save the file

Tailwind utilities only; no JS.

2. Use it

index.html
<nav aria-label="Articles" class="mx-auto flex w-full justify-center">
  <ul class="flex flex-row items-center gap-1">
    <li><a href="/articles?page=1" aria-label="Previous page" class="…">‹ Previous</a></li>
    <li><a href="/articles?page=1" class="…">1</a></li>
    <li><a aria-current="page" class="… bg-primary text-primary-foreground">2</a></li>
    <li><a href="/articles?page=3" aria-label="Next page" class="…">Next ›</a></li>
  </ul>
</nav>
View source
index.html
<!--
  shadcn-htmx — raw HTML pagination snippet.
  <nav> landmark with aria-label, aria-current="page" on the active link,
  built-in aria-labels for previous / next.
-->

<nav data-slot="pagination" aria-label="Articles"
     class="mx-auto flex w-full justify-center">
  <ul class="flex flex-row items-center gap-1">
    <li>
      <!-- rel="prev"/"next": WHATWG sequence link types for paged content. -->
      <a href="/articles?page=1" data-slot="pagination-prev" aria-label="Previous page" rel="prev"
         class="inline-flex h-9 min-w-9 items-center justify-center gap-1 rounded-md px-3 pl-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
        ‹ Previous
      </a>
    </li>
    <li>
      <a href="/articles?page=1" data-slot="pagination-link"
         class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">1</a>
    </li>
    <li>
      <a href="/articles?page=2" data-slot="pagination-link" aria-current="page"
         class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none bg-primary text-primary-foreground hover:bg-primary/90">2</a>
    </li>
    <li>
      <a href="/articles?page=3" data-slot="pagination-link"
         class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">3</a>
    </li>
    <li>
      <span data-slot="pagination-ellipsis" aria-hidden="true"
            class="flex h-9 w-9 items-center justify-center text-muted-foreground">…</span>
    </li>
    <li>
      <a href="/articles?page=3" data-slot="pagination-next" aria-label="Next page" rel="next"
         class="inline-flex h-9 min-w-9 items-center justify-center gap-1 rounded-md px-3 pr-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
        Next ›
      </a>
    </li>
  </ul>
</nav>

Examples

Basic — static links

Each page is a real <a href> so it works without JS and is bookmarkable / shareable.

The <nav> landmark + aria-label lets AT users jump straight to it from the landmarks menu. aria-current="page" is the WAI-recommended way to mark the active page — styling alone isn't enough.

import { Pagination, PaginationItem, PaginationLink,
  PaginationPrevious, PaginationNext, PaginationEllipsis } from "@/components/ui/pagination"

<Pagination ariaLabel="Articles">
  <PaginationItem><PaginationPrevious href="/articles?page=1" /></PaginationItem>
  <PaginationItem><PaginationLink href="/articles?page=1">1</PaginationLink></PaginationItem>
  <PaginationItem><PaginationLink active>2</PaginationLink></PaginationItem>
  <PaginationItem><PaginationLink href="/articles?page=3">3</PaginationLink></PaginationItem>
  <PaginationItem><PaginationEllipsis /></PaginationItem>
  <PaginationItem><PaginationNext href="/articles?page=3" /></PaginationItem>
</Pagination>
{% from "components/pagination.html" import pagination_open, pagination_close,
   pagination_prev, pagination_next, pagination_page, pagination_ellipsis %}

{{ pagination_open(aria_label="Articles") }}
  {{ pagination_prev(href="/articles?page=1") }}
  {{ pagination_page(1, href="/articles?page=1") }}
  {{ pagination_page(2, active=true) }}
  {{ pagination_page(3, href="/articles?page=3") }}
  {{ pagination_ellipsis() }}
  {{ pagination_next(href="/articles?page=3") }}
{{ pagination_close() }}
{{template "pagination" (dict "AriaLabel" "Articles" "Body" (htmlSafe `
  {{template "pagination_prev" (dict "Href" "/articles?page=1")}}
  {{template "pagination_page" (dict "N" 1 "Href" "/articles?page=1")}}
  {{template "pagination_page" (dict "N" 2 "Active" true)}}
  {{template "pagination_ellipsis" (dict)}}
  {{template "pagination_next" (dict "Href" "/articles?page=3")}}`))}}
<.pagination aria-label="Articles">
  <.pagination_prev href={~p"/articles?page=1"} />
  <.pagination_page n={1} href={~p"/articles?page=1"} />
  <.pagination_page n={2} active />
  <.pagination_page n={3} href={~p"/articles?page=3"} />
  <.pagination_ellipsis />
  <.pagination_next href={~p"/articles?page=3"} />
</.pagination>
<nav data-slot="pagination" aria-label="Articles" class="mx-auto flex w-full justify-center">
  <ul class="flex flex-row items-center gap-1">
    <li class="">
      <a href="?page=1" rel="prev" data-slot="pagination-prev" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none gap-1 pl-2.5" aria-label="Previous page">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
          <polyline points="15 18 9 12 15 6">
          </polyline>
        </svg>
        <span>Previous</span>
      </a>
    </li>
    <li class="">
      <a href="?page=1" data-slot="pagination-link" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">1</a>
    </li>
    <li class="">
      <button type="button" data-slot="pagination-link" aria-current="page" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none bg-primary text-primary-foreground hover:bg-primary/90">2</button>
    </li>
    <li class="">
      <a href="?page=3" data-slot="pagination-link" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">3</a>
    </li>
    <li class="">
      <span data-slot="pagination-ellipsis" aria-hidden="true" class="flex h-9 w-9 items-center justify-center text-muted-foreground">…</span>
    </li>
    <li class="">
      <a href="?page=3" rel="next" data-slot="pagination-next" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none gap-1 pr-2.5" aria-label="Next page">
        <span>Next</span>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
          <polyline points="9 18 15 12 9 6">
          </polyline>
        </svg>
      </a>
    </li>
  </ul>
</nav>

htmx — partial swap, no full reload

Each page link is hx-get + hx-target. The server returns just the content + new pagination, htmx swaps innerHTML.

Click around — the URL doesn't change but the content (and the highlighted page) does. For deep links pair with hx-push-url so back/forward + bookmarking still work.

Showing page 1 of 5

  • Article 1 — Intro to htmx
  • Article 2 — Hypermedia controls
  • Article 3 — Server-rendered SPAs
<PaginationLink active={n === active}
  hx-get={`/api/articles?page=${n}`}
  hx-target="#article-list"
  hx-swap="innerHTML"
>{n}</PaginationLink>
{{ pagination_page(n, active=(n == active),
  hx_get="/api/articles?page=" ~ n,
  hx_target="#article-list",
  hx_swap="innerHTML") }}
{{template "pagination_page" (dict "N" $n "Active" (eq $n .Active)
  "Href" "/api/articles?page=…" )}}
<.pagination_page n={n} active={n == @active}
  hx-get={~p"/api/articles?page=#{n}"}
  hx-target="#article-list"
  hx-swap="innerHTML" />
<div id="ex-pag-host" class="w-full max-w-2xl">
  <div aria-live="polite" class="mb-4 grid gap-3 rounded-lg border p-4 text-sm">
    <p class="font-medium">Showing page 1 of 5</p>
    <ul class="grid gap-1 text-muted-foreground">
      <li>Article 1 — Intro to htmx</li>
      <li>Article 2 — Hypermedia controls</li>
      <li>Article 3 — Server-rendered SPAs</li>
    </ul>
  </div>
  <nav data-slot="pagination" aria-label="Demo articles" class="mx-auto flex w-full justify-center">
    <ul class="flex flex-row items-center gap-1">
      <li class="">
        <button disabled="" type="button" data-slot="pagination-prev" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none gap-1 pl-2.5 pointer-events-none opacity-50" aria-label="Previous page" aria-disabled="true" hx-get="/pagination/page?page=1" hx-target="#ex-pag-host" hx-swap="innerHTML">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
            <polyline points="15 18 9 12 15 6">
            </polyline>
          </svg>
          <span>Previous</span>
        </button>
      </li>
      <li class="">
        <button type="button" data-slot="pagination-link" aria-current="page" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none bg-primary text-primary-foreground hover:bg-primary/90" data-test="page-1" hx-get="/pagination/page?page=1" hx-target="#ex-pag-host" hx-swap="innerHTML">1</button>
      </li>
      <li class="">
        <button type="button" data-slot="pagination-link" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="page-2" hx-get="/pagination/page?page=2" hx-target="#ex-pag-host" hx-swap="innerHTML">2</button>
      </li>
      <li class="">
        <button type="button" data-slot="pagination-link" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="page-3" hx-get="/pagination/page?page=3" hx-target="#ex-pag-host" hx-swap="innerHTML">3</button>
      </li>
      <li class="">
        <button type="button" data-slot="pagination-link" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="page-4" hx-get="/pagination/page?page=4" hx-target="#ex-pag-host" hx-swap="innerHTML">4</button>
      </li>
      <li class="">
        <button type="button" data-slot="pagination-link" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="page-5" hx-get="/pagination/page?page=5" hx-target="#ex-pag-host" hx-swap="innerHTML">5</button>
      </li>
      <li class="">
        <button type="button" data-slot="pagination-next" class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none gap-1 pr-2.5" aria-label="Next page" hx-get="/pagination/page?page=2" hx-target="#ex-pag-host" hx-swap="innerHTML">
          <span>Next</span>
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true">
            <polyline points="9 18 15 12 9 6">
            </polyline>
          </svg>
        </button>
      </li>
    </ul>
  </nav>
</div>

API Reference

<Pagination>

PropTypeDefaultDescription
relstring"prev" / "next"
Forwarded to the Previous/Next <a>. Defaults to the WHATWG sequence link types rel="prev" / rel="next"; overridable. Only emitted on the enabled anchor (dropped when disabled renders a <button>).WHATWGLink types: prev/next
disabledbooleanfalse
Dead-ends Previous/Next at the first/last page. Drops href and renders a native <button disabled> (removed from tab order, activation suppressed) while keeping aria-disabled="true" — aria-disabled alone does not block keyboard activation.MDNaria-disabled
ariaLabelstring"Pagination"
Landmark name for the <nav>.
classstring
Extra Tailwind classes appended to the root element.