Skip to content
Primitiv home
Framework
Consumption mode

Pagination

stableSource

Page navigation for a paged collection — a row of square page cells with prev/next chevrons, or a compact "Page X of Y" readout. Truncated ranges collapse behind an ellipsis that opens a menu of the hidden pages.

Playground

Density

Preview

Size
Variant
import { usePagination } from "@primitiv-ui/react";import {  Pagination, PaginationList, PaginationItem, PaginationLink,  PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationMenuItem,} from "@/components/ui/pagination";
const { page, items, setPage, next, previous, canPrevious, canNext } =  usePagination({ totalItems, pageSize: 10 });
<Pagination size="md" label="Results">  <PaginationList>    <PaginationItem><PaginationPrevious disabled={!canPrevious} onClick={previous} /></PaginationItem>    {items.map((item) =>      item.type === "page" ? (        <PaginationItem key={item.page}>          <PaginationLink isActive={item.page === page} onClick={() => setPage(item.page)}>            {item.page}          </PaginationLink>        </PaginationItem>      ) : (/* an ellipsis menu of the hidden pages */)    )}    <PaginationItem><PaginationNext disabled={!canNext} onClick={next} /></PaginationItem>  </PaginationList></Pagination>

Density is set by a data-density ancestor — the Context system, not a Pagination prop.

Installation

npx primitiv add pagination

Import

import { Pagination } from "@/components/ui/pagination";

No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.

Anatomy

<Pagination label="Results">  <PaginationSummary>…</PaginationSummary>          {/* optional */}  <PaginationList>    <PaginationItem><PaginationPrevious /></PaginationItem>    <PaginationItem><PaginationLink isActive>1</PaginationLink></PaginationItem>    <PaginationItem>      <PaginationEllipsis><PaginationMenuItem>…</PaginationMenuItem></PaginationEllipsis>    </PaginationItem>    <PaginationItem><PaginationNext /></PaginationItem>  </PaginationList>  <PaginationTrailing>…</PaginationTrailing>        {/* optional */}</Pagination>

Props

Pagination

Extends HTMLElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
labelstring"Pagination"Accessible name for the navigation landmark. Give each Pagination on a page a distinct one — two identically named landmarks are indistinguishable in a landmark list.
size"xs" | "sm" | "md" | "lg" | "xl"mdControl size; data-density scales each size further.
variant"numbered" | "compact"numberedNumbered page cells, or a compact Page X of Y readout for narrow containers.

PaginationSummary

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

PaginationList

Extends HTMLUListElement — every native attribute of that element is accepted and forwarded.

No props of its own.

PaginationItem

Extends HTMLLIElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildbooleanfalseRenders the child element instead of a native <button>, merging all props — aria-, data-, event handlers, ref — onto it via Slot. type is not forwarded in this mode; the child owns its own type semantics. See the asChild example on Button.
childrenReactNode—Button content. Under asChild, becomes the single child element Slot merges props onto.
isActivebooleanfalseMarks this cell as the current page: renders primary rather than secondary, and sets aria-current="page".
refRef<HTMLButtonElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement, or — under asChild — merged onto the rendered child via Slot.
type"button" | "submit" | "reset""button"The button's native type attribute, restricted to the three valid values. Defaults to "button" (not the DOM's own default of "submit"), so a Button placed inside a <form> never triggers an accidental submit unless set explicitly.

PaginationPrevious

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildbooleanfalseRenders the child element instead of a native <button>, merging all props — aria-, data-, event handlers, ref — onto it via Slot. type is not forwarded in this mode; the child owns its own type semantics. See the asChild example on Button.
labelstring"Go to previous page"
refRef<HTMLButtonElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement, or — under asChild — merged onto the rendered child via Slot.
type"button" | "submit" | "reset""button"The button's native type attribute, restricted to the three valid values. Defaults to "button" (not the DOM's own default of "submit"), so a Button placed inside a <form> never triggers an accidental submit unless set explicitly.

PaginationNext

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildbooleanfalseRenders the child element instead of a native <button>, merging all props — aria-, data-, event handlers, ref — onto it via Slot. type is not forwarded in this mode; the child owns its own type semantics. See the asChild example on Button.
labelstring"Go to previous page"
refRef<HTMLButtonElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement, or — under asChild — merged onto the rendered child via Slot.
type"button" | "submit" | "reset""button"The button's native type attribute, restricted to the three valid values. Defaults to "button" (not the DOM's own default of "submit"), so a Button placed inside a <form> never triggers an accidental submit unless set explicitly.

PaginationEllipsis

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildbooleanfalseRenders the child element instead of a native <button>, merging all props — aria-, data-, event handlers, ref — onto it via Slot. type is not forwarded in this mode; the child owns its own type semantics. See the asChild example on Button.
childrenReactNode—The hidden pages, as PaginationMenuItem children.
labelstring"Show more pages"
refRef<HTMLButtonElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement, or — under asChild — merged onto the rendered child via Slot.
type"button" | "submit" | "reset""button"The button's native type attribute, restricted to the three valid values. Defaults to "button" (not the DOM's own default of "submit"), so a Button placed inside a <form> never triggers an accidental submit unless set explicitly.

PaginationMenuItem

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultDescription
asChildbooleanfalseRender the composed child element (with menuitem semantics) instead of the default <li role="menuitem">.
childrenReactNode—The item's visible content (or an element, with asChild).
disabledbooleanfalseMark the item non-interactive. Sets aria-disabled="true" and skips the item during arrow navigation, typeahead, and activation.
onSelect(event: Event) => void—Fires when the item is activated (click, Enter, or Space). Called with a cancellable event whose preventDefault() skips the auto-close that Dropdown performs after selection.
refRef<HTMLLIElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLLIElement.

PaginationStatus

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

PaginationTrailing

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Styling contract

Control frame

--primitiv-pagination-gap--primitiv-pagination-control-gap--primitiv-pagination-icon-size--primitiv-pagination-menu-min-inline-size--primitiv-pagination-menu-max-block-size--primitiv-pagination-font-family--primitiv-pagination-font-size--primitiv-pagination-font-weight--primitiv-pagination-line-height

Item

--primitiv-pagination-item-min-inline-size

Group label & separator

--primitiv-pagination-group-gap

Accessibility

  • It is a <nav> landmark — name it. Pagination renders a <nav> and needs a label (aria-label), since a page can have more than one navigation landmark; “Search results” or “Table pages” distinguishes them in a screen reader's landmark list.
  • The current page is marked with aria-current, not just colour. isActive on a PaginationLink sets aria-current="page", which is how assistive technology announces which page you are on — the highlight alone is invisible to it.
  • Prev/next carry their own names and disabled state. PaginationPrevious / PaginationNext are icon-only, so they set an aria-label (Go to previous page) themselves; wire disabled from canPrevious / canNext so the first/last page can't be over-stepped.
  • The ellipsis is a real menu, not decoration. PaginationEllipsis opens a dropdown of the collapsed pages (PaginationMenuItems), so every page stays reachable by keyboard — a bare “…” glyph would strand the hidden pages.
  • Links or buttons? These are button-based controls driving client state, not <a href>s — right for a SPA. If each page is a real URL, render PaginationLink asChild around your router's link so they are genuine, right-clickable links.
  • The state is the hook's, the arithmetic is too. usePagination clamps out-of-range requests and computes the truncated range, so you don't hand-roll off-by-one page maths that a keyboard or screen-reader user then trips over.

Examples

Numbered pages

The default: a row of page cells between prev/next chevrons, driven by usePagination. The hook returns items — a mix of page and gap entries — so a long range collapses behind a PaginationEllipsis whose menu lists the hidden pages (this demo is 10 pages; click the …). isActive on the current PaginationLink is what sets aria-current="page"; disable the chevrons from canPrevious / canNext.

Density
import { usePagination } from "@primitiv-ui/react";import {  Pagination, PaginationList, PaginationItem, PaginationLink,  PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationMenuItem,} from "@/components/ui/pagination";
const { page, items, setPage, next, previous, canPrevious, canNext } =  usePagination({ totalItems: rows.length, pageSize: 10 });
<Pagination label="Search results">  <PaginationList>    <PaginationItem><PaginationPrevious disabled={!canPrevious} onClick={previous} /></PaginationItem>    {items.map((item, i) =>      item.type === "page" ? (        <PaginationItem key={`p-${item.page}`}>          <PaginationLink isActive={item.page === page} onClick={() => setPage(item.page)}>            {item.page}          </PaginationLink>        </PaginationItem>      ) : (        <PaginationItem key={`g-${i}`}>          <PaginationEllipsis>            {item.pages.map((h) => (              <PaginationMenuItem key={h} onSelect={() => setPage(h)}>{h}</PaginationMenuItem>            ))}          </PaginationEllipsis>        </PaginationItem>      ),    )}    <PaginationItem><PaginationNext disabled={!canNext} onClick={next} /></PaginationItem>  </PaginationList></Pagination>

Compact

variant="compact" drops the number cells for a PaginationStatus readout between the chevrons — for narrow containers, and the intended default in a data-table footer. Same hook, less chrome: you show Page {page} of {pageCount} instead of every cell.

Density
import { usePagination } from "@primitiv-ui/react";import {  Pagination, PaginationList, PaginationItem, PaginationLink,  PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationMenuItem,} from "@/components/ui/pagination";
const { page, pageCount, next, previous, canPrevious, canNext } =  usePagination({ totalItems: rows.length, pageSize: 10 });
<Pagination variant="compact" label="Results">  <PaginationList>    <PaginationItem><PaginationPrevious disabled={!canPrevious} onClick={previous} /></PaginationItem>    <PaginationItem><PaginationStatus>Page {page} of {pageCount}</PaginationStatus></PaginationItem>    <PaginationItem><PaginationNext disabled={!canNext} onClick={next} /></PaginationItem>  </PaginationList></Pagination>

Summary and jump-to

Two optional slots wrap the list: PaginationSummary before it for a “showing X–Y of N” line (from the hook's startIndex / endIndex), and PaginationTrailing after it for a jump-to control — a native Select here, so far pages are one action away rather than many clicks. Omit either and nothing renders.

Density
import { usePagination } from "@primitiv-ui/react";import {  Pagination, PaginationList, PaginationItem, PaginationLink,  PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationMenuItem,} from "@/components/ui/pagination";import { Select, SelectItem } from "@/components/ui/select";
<Pagination label="Results">  <PaginationSummary>Showing {startIndex + 1}–{endIndex} of {total}</PaginationSummary>  <PaginationList>{/* … prev / pages / next … */}</PaginationList>  <PaginationTrailing>    <span>Go to</span>    <Select native value={String(page)} onValueChange={(v) => setPage(Number(v))} aria-label="Go to page">      {pages.map((n) => <SelectItem key={n} value={String(n)}>{n}</SelectItem>)}    </Select>  </PaginationTrailing></Pagination>

Sizes and density

Five sizes, each rescaling again with the nearest data-density ancestor — the same framed-control/* scale the chevrons and cells share with Button and Input, so a pagination row lines up with the controls around it.

Density
import { usePagination } from "@primitiv-ui/react";import {  Pagination, PaginationList, PaginationItem, PaginationLink,  PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationMenuItem,} from "@/components/ui/pagination";
<div data-density="comfortable">  <Pagination size="sm" label="Results">{/* … */}</Pagination></div>