HextaUI

usePagination

Turns a page and a page count into the list of pages and ellipses to render, keeping its length steady as the page moves.

siblings
7 items
"use client"

import * as React from "react"
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePagination } from "@/hooks/use-pagination"

export function UsePaginationDemo() {
  const [page, setPage] = React.useState(6)
  const [siblings, setSiblings] = React.useState(1)
  const pagination = usePagination({ page, count: 20, siblings })

  return (
    <div className="flex w-full flex-col items-center gap-6">
      <nav aria-label="Pagination" className="flex items-center gap-1">
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label="Previous page"
          disabled={!pagination.hasPrevious}
          onClick={() => setPage(pagination.page - 1)}
        >
          <IconChevronLeft className="rtl:rotate-180" />
        </Button>
        {pagination.items.map((item) =>
          item.type === "ellipsis" ? (
            <span
              key={item.position}
              aria-hidden="true"
              className="w-8 text-center text-sm text-muted-foreground"
            >
              …
            </span>
          ) : (
            <Button
              key={item.page}
              variant={item.page === pagination.page ? "secondary" : "ghost"}
              size="icon-sm"
              aria-current={item.page === pagination.page ? "page" : undefined}
              onClick={() => setPage(item.page)}
            >
              {item.page}
            </Button>
          )
        )}
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label="Next page"
          disabled={!pagination.hasNext}
          onClick={() => setPage(pagination.page + 1)}
        >
          <IconChevronRight className="rtl:rotate-180" />
        </Button>
      </nav>
      <div className="flex items-center gap-3 text-sm text-muted-foreground">
        siblings
        <ToggleGroup
          size="sm"
          variant="outline"
          value={[String(siblings)]}
          onValueChange={(value) => {
            if (value[0]) {
              setSiblings(Number(value[0]))
            }
          }}
        >
          <ToggleGroupItem value="0">0</ToggleGroupItem>
          <ToggleGroupItem value="1">1</ToggleGroupItem>
          <ToggleGroupItem value="2">2</ToggleGroupItem>
        </ToggleGroup>
      </div>
      <code className="font-mono text-xs text-muted-foreground">
        {pagination.items.length} items
      </code>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/use-pagination.json

Adds the hook and anything it depends on to your project.

import { usePagination } from "@/hooks/use-pagination"
const { items, page, hasPrevious, hasNext } = usePagination({
  page: currentPage,
  count: totalPages,
})

items.map((item) =>
  item.type === "ellipsis" ? (
    <span key={item.position}>…</span>
  ) : (
    <a key={item.page} href={`?page=${item.page}`}>{item.page}</a>
  )
)

The hook only does the math. It returns the list of pages and ellipses to render and leaves the markup to you, which is how Pagination builds its links. Use it to build your own pager, like dots for a carousel or a page picker in a table footer.

The list always shows the first and last boundaries pages, and siblings pages on each side of the current one. An ellipsis fills any gap of two pages or more. A gap of exactly one page shows that page instead, because an ellipsis there would hide no more than it takes up.

count: 20, siblings: 1, boundaries: 1

page 1    1  2  3  4  5  …  20
page 6    1  …  5  6  7  …  20
page 20   1  …  16 17 18 19 20

Once there are enough pages, the list always has 2 × boundaries + 2 × siblings + 3 items. Near the ends, the window widens instead of shrinking. Because the length never changes, the pager keeps its width, and the next and previous buttons stay under the pointer as you click through.

  • count and page are clamped: a page past the end becomes the last page, and anything that isn't a finite number falls back to the default.
  • A count of 0 returns no items and a page of 0, so an empty table can render nothing without a special case.
  • siblings and boundaries go from 0 to 10.
  • Ellipses have a stable position of start or end. Use it as the React key.

Dots

With boundaries: 0 the list is just a window around the current page. Ellipses become small dots, so a long set of slides never needs more than five targets.

"use client"

import * as React from "react"

import { usePagination } from "@/hooks/use-pagination"

export function UsePaginationDots() {
  const [page, setPage] = React.useState(1)
  const { items } = usePagination({
    page,
    count: 12,
    siblings: 1,
    boundaries: 0,
  })

  return (
    <nav aria-label="Slides" className="flex items-center gap-1">
      {items.map((item) =>
        item.type === "ellipsis" ? (
          <span
            key={item.position}
            aria-hidden="true"
            className="size-1 rounded-full bg-border"
          />
        ) : (
          <button
            key={item.page}
            type="button"
            aria-label={`Slide ${item.page}`}
            aria-current={item.page === page ? "true" : undefined}
            onClick={() => setPage(item.page)}
            className="flex size-6 items-center justify-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
          >
            <span className="h-2 w-2 rounded-full bg-muted-foreground/30 transition-all duration-300 ease-out-quint in-aria-[current=true]:w-5 in-aria-[current=true]:bg-foreground motion-reduce:transition-none" />
          </button>
        )
      )}
    </nav>
  )
}
  • Mark the current page with aria-current="page" and wrap the list in a <nav> with a label.
  • Hide ellipses from screen readers with aria-hidden. They carry no information that the page numbers don't.
  • Show page numbers with tabular-nums so the buttons don't change width as the digits change.
PropTypeDefault
countTotal number of pages.
number–
pageThe current page, starting at 1.
number1
siblingsPages to show on each side of the current page.
number1
boundariesPages to always show at the start and the end.
number1
PropertyDescription
itemsPaginationItemData[] to render, in order.
pageThe clamped current page.
countThe clamped page count.
hasPreviousWhether there's a page before this one.
hasNextWhether there's a page after this one.
type PaginationItemData =
  | { type: "page"; page: number }
  | { type: "ellipsis"; position: "start" | "end" }

Pagination.