Skip to content
Monogem

Component · Navigation & Feedback

Pagination

Overview

Splits a long, ordered result set into numbered pages and moves between them. It communicates three things at a glance: which page you are on, which pages exist, and which directions are unavailable (you can’t go back from page 1). Use it for search results, tables, and archives where the total is known and the user benefits from addressable, linkable pages.

Reach for something else when: the list is effectively endless or the order is a feed (infinite scroll / “Load more”); the total is tiny (just show it all); or the user is stepping through a task (that’s a stepper, not pagination).

Pagination ships as compound parts (like Tabs / Breadcrumb); the caller computes the visible page window and renders the items.

Anatomy

Pagination              <nav aria-label="Pagination">
└─ PaginationContent    <ul>
   ├─ PaginationItem <li> → PaginationPrevious   (<a>, direction control)
   ├─ PaginationItem <li> → PaginationLink       (<a>, a page number; isActive = current)
   ├─ PaginationItem <li> → PaginationEllipsis   (<span>; glyph hidden, "More pages" announced)
   ├─ PaginationItem <li> → PaginationLink
   └─ PaginationItem <li> → PaginationNext       (<a>, direction control)
  • Pagination — the <nav> landmark, labelled aria-label="Pagination" so it is distinct from the primary nav.
  • PaginationContent — the <ul> row of controls.
  • PaginationItem — one control’s <li> wrapper.
  • PaginationLink — a page target. Styled with buttonVariants — ghost normally, outline for the current page. isActive also sets aria-current="page".
  • PaginationPrevious / PaginationNext — the direction controls, with a leading / trailing chevron and a visible label in a <span> (hide the text on a narrow layout via className; the aria-label remains). disabled marks the end of the range.
  • PaginationEllipsis — a non-interactive gap marker for page numbers the caller has left out. The glyph is aria-hidden; the sr-only “More pages” beside it is announced.

PaginationLink / PaginationPrevious / PaginationNext each render a native <a> and forward every prop (href, onClick, …) straight through — there is no asChild. For client-side routing, put buttonVariants({ variant, size }) on your router’s own link element, the same escape hatch Button documents.

Live Example

Windowed — first pages, an ellipsis, the last page (page 1 of 8)

Compact — every page shown, no ellipsis (page 1 of 5)

Code Example

pagination-showcase.tsx
"use client";

import * as React from "react";

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

/**
* Live Pagination example — `"use client"` only to hold the demos' current-page
* state; the Pagination parts themselves are server components. Every control
* is a real `<a>` with an `href` (the page route in a real app) plus an
* `onClick` that calls `preventDefault()` so the demo updates state in place
* and never navigates or scrolls the document. The disabled Previous / Next at
* the ends drop both `href` and the handler automatically.
*/
const WINDOWED_TOTAL = 8;
const COMPACT_TOTAL = 5;

export function PaginationShowcase() {
const [windowed, setWindowed] = React.useState(1);
const [compact, setCompact] = React.useState(1);

const stay =
(set: (n: number) => void, n: number) => (event: React.MouseEvent) => {
event.preventDefault();
set(n);
};

const near = [1, 2].filter((n) => n < WINDOWED_TOTAL);

return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Windowed — first pages, an ellipsis, the last page (page {windowed} of{" "}
{WINDOWED_TOTAL})
</h3>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`?page=${windowed - 1}`}
onClick={stay(setWindowed, Math.max(1, windowed - 1))}
disabled={windowed === 1}
/>
</PaginationItem>

{near.map((n) => (
<PaginationItem key={n}>
<PaginationLink
href={`?page=${n}`}
onClick={stay(setWindowed, n)}
isActive={windowed === n}
>
{n}
</PaginationLink>
</PaginationItem>
))}

<PaginationItem>
<PaginationEllipsis />
</PaginationItem>

<PaginationItem>
<PaginationLink
href={`?page=${WINDOWED_TOTAL}`}
onClick={stay(setWindowed, WINDOWED_TOTAL)}
isActive={windowed === WINDOWED_TOTAL}
>
{WINDOWED_TOTAL}
</PaginationLink>
</PaginationItem>

<PaginationItem>
<PaginationNext
href={`?page=${windowed + 1}`}
onClick={stay(
setWindowed,
Math.min(WINDOWED_TOTAL, windowed + 1),
)}
disabled={windowed === WINDOWED_TOTAL}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Compact — every page shown, no ellipsis (page {compact} of{" "}
{COMPACT_TOTAL})
</h3>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={`?page=${compact - 1}`}
onClick={stay(setCompact, Math.max(1, compact - 1))}
disabled={compact === 1}
/>
</PaginationItem>

{Array.from({ length: COMPACT_TOTAL }, (_, i) => i + 1).map((n) => (
<PaginationItem key={n}>
<PaginationLink
href={`?page=${n}`}
onClick={stay(setCompact, n)}
isActive={compact === n}
>
{n}
</PaginationLink>
</PaginationItem>
))}

<PaginationItem>
<PaginationNext
href={`?page=${compact + 1}`}
onClick={stay(setCompact, Math.min(COMPACT_TOTAL, compact + 1))}
disabled={compact === COMPACT_TOTAL}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</section>
</div>
);
}

Variants

No visual variants. The footprint comes from buttonVariants:

PropValuesEffect
PaginationLink sizeicon (default, 40px square) · smPage-number button footprint
PaginationPrevious / PaginationNext sizesm (default) · iconDirection-control footprint; icon + hidden label text = an icon-only control
PaginationLink isActivefalse (default) · trueghost → outline, and aria-current="page"

The number of page links shown, and where the ellipsis sits, are not the component’s concern — the caller passes exactly the items it wants rendered.

States

PartStateLook
PaginationLink (not current)Restingghost — transparent, --foreground text
PaginationLink (not current)Hover / active--accent / --accent-foreground (Button’s ghost)
PaginationLinkCurrent (isActive)outline — --border hairline on --background, aria-current="page"
PaginationLink / edgesFocus (keyboard)2px --ring ring, offset in --background (Button’s contract)
PaginationPrevious / PaginationNextdisabledaria-disabled="true", href removed, event handlers stripped, pointer-events-none, opacity-50
PaginationEllipsis—Decorative, --muted-foreground, no states

The disabled contract on Previous / Next wins absolutely. When disabled is set the component renders a bare <a> with no href (so it drops out of the tab order natively — no synthetic keyboard handling to simulate disabled), strips every caller event handler (onClick, onKeyDown, …), forces aria-disabled="true", and applies the non-interactive styling — regardless of any href / onClick the caller passed. A disabled direction control is never actionable.

Usage Guidance

Tokens

Pagination composes existing tokens through Button's ghost / outline treatments — the same sizes and focus ring as Button. Every value comes through buttonVariants, which maps only to existing semantic and scale tokens.

TokenWhere used (via buttonVariants)Rationale
--accent / --accent-foregroundHover / active on a non-current page link (ghost)Same low-emphasis hover as Button’s ghost and outline
--border / --background / --foregroundThe current page link (outline)The current page reads as a pressed / bounded control without a filled colour
--ring / --backgroundKeyboard focus ring + its offset colour, on every controlThe system-wide focus token and offset, identical to Button
--radius-mdControl corner radiusRadius’ “buttons” step — Pagination controls are buttons
--muted-foregroundPaginationEllipsis glyphColor’s quiet-secondary text
--scale-1 (gap-1, 4px)Gap between controls in the rowThe tight rhythm Spacing uses for a control cluster
--scale-10 (size-10, 40px)icon control footprintButton’s icon size — a comfortable 40px pointer target
--scale-4 (size-4, 16px)Chevron + ellipsis glyph sizeIcons’ inline size, matching Button

Do / Don’t

Do

  • Give PaginationLink an href that points at the real page route (?page=3), so pages are linkable, bookmarkable, and open in a new tab.
  • Mark exactly one link isActive — the page currently shown.
  • Disable PaginationPrevious on the first page and PaginationNext on the last.
  • Keep the window small and stable: first page, current ± 1, last page, with a PaginationEllipsis bridging each gap. Aim for a constant control count so the row doesn’t jump width as the user pages.
  • On narrow viewports, drop to just Previous / a “Page 3 of 20” label / Next, or hide the direction-control text and keep the chevrons.

Don’t

  • Render every page number for a 400-page result — that’s what the ellipsis is for.
  • Use <button> for Previous / Next — they navigate, so they’re links. (The live example keeps a real href on every control and calls preventDefault() in an onClick purely so the demo updates local state instead of navigating or scrolling the page.)
  • Leave a disabled Previous / Next with a live href or onClick — disabled means not actionable, full stop.
  • Make the current-page link look identical to the others — isActive (the outline treatment) is what makes “you are here” visible.
  • Put an aria-current on more than one link.

Accessibility

  • The controls are wrapped in <nav aria-label="Pagination"> — a distinct navigation landmark. The label defaults to “Pagination” and is applied after any spread props so it can’t be broken by accident; pass your own aria-label to name a specific region when a page has more than one paginated area (WAI-ARIA APG recommends distinct names in that case).
  • PaginationContent is a <ul> — assistive tech announces the set size and position.
  • The current page link carries aria-current="page" and remains a real link (WAI-ARIA APG pagination pattern): activating it reloads the page you’re on, which is harmless, and the aria-current is what conveys state. This is a deliberate asymmetry with Breadcrumb, whose current item is a non-link <span> because a breadcrumb’s last item has nowhere to go.
  • PaginationPrevious / PaginationNext always have an accessible name (aria-label="Go to previous page" / "…next page"), so an icon-only rendering is still announced. When disabled, aria-disabled="true" is exposed and the missing href takes the control out of the tab order — a screen-reader user still finds it and hears it is unavailable, but it can’t be activated.
  • PaginationEllipsis hides only its glyph (aria-hidden on the MoreHorizontal); the sr-only “More pages” sits outside that, so it is announced — the caller renders only a window of page links, so this is what tells a screen-reader user that pages were skipped between the numbers on either side. It is static text, not an interactive item.
  • Keyboard: nothing custom. Controls are in natural tab order; Tab / Shift+Tab move between them, Enter activates a link. No roving tabindex — this is a list of links, not a composite widget.
  • Contrast: ghost and outline are Button’s own treatments, unchanged — --foreground text on --background (near-maximal in both themes), --accent-foreground on --accent on hover (Button’s measured contrast). The focus ring is the system --ring, offset in --background.

Related Components / Patterns

  • Breadcrumb — orientation within a hierarchy rather than a result set.
  • Navigation — how the navigation primitives combine on one screen.