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, labelledaria-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 withbuttonVariants—ghostnormally,outlinefor the current page.isActivealso setsaria-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 viaclassName; thearia-labelremains).disabledmarks the end of the range.PaginationEllipsis— a non-interactive gap marker for page numbers the caller has left out. The glyph isaria-hidden; thesr-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
"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:
| Prop | Values | Effect |
|---|---|---|
PaginationLink size | icon (default, 40px square) · sm | Page-number button footprint |
PaginationPrevious / PaginationNext size | sm (default) · icon | Direction-control footprint; icon + hidden label text = an icon-only control |
PaginationLink isActive | false (default) · true | ghost → 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
| Part | State | Look |
|---|---|---|
PaginationLink (not current) | Resting | ghost — transparent, --foreground text |
PaginationLink (not current) | Hover / active | --accent / --accent-foreground (Button’s ghost) |
PaginationLink | Current (isActive) | outline — --border hairline on --background, aria-current="page" |
PaginationLink / edges | Focus (keyboard) | 2px --ring ring, offset in --background (Button’s contract) |
PaginationPrevious / PaginationNext | disabled | aria-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.
| Token | Where used (via buttonVariants) | Rationale |
|---|---|---|
--accent / --accent-foreground | Hover / active on a non-current page link (ghost) | Same low-emphasis hover as Button’s ghost and outline |
--border / --background / --foreground | The current page link (outline) | The current page reads as a pressed / bounded control without a filled colour |
--ring / --background | Keyboard focus ring + its offset colour, on every control | The system-wide focus token and offset, identical to Button |
--radius-md | Control corner radius | Radius’ “buttons” step — Pagination controls are buttons |
--muted-foreground | PaginationEllipsis glyph | Color’s quiet-secondary text |
--scale-1 (gap-1, 4px) | Gap between controls in the row | The tight rhythm Spacing uses for a control cluster |
--scale-10 (size-10, 40px) | icon control footprint | Button’s icon size — a comfortable 40px pointer target |
--scale-4 (size-4, 16px) | Chevron + ellipsis glyph size | Icons’ inline size, matching Button |
Do / Don’t
Do
- Give
PaginationLinkanhrefthat 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
PaginationPreviouson the first page andPaginationNexton the last. - Keep the window small and stable: first page, current ± 1, last page, with a
PaginationEllipsisbridging 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>forPrevious/Next— they navigate, so they’re links. (The live example keeps a realhrefon every control and callspreventDefault()in anonClickpurely so the demo updates local state instead of navigating or scrolling the page.) - Leave a disabled
Previous/Nextwith a livehreforonClick— disabled means not actionable, full stop. - Make the current-page link look identical to the others —
isActive(theoutlinetreatment) is what makes “you are here” visible. - Put an
aria-currenton 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 ownaria-labelto name a specific region when a page has more than one paginated area (WAI-ARIA APG recommends distinct names in that case). PaginationContentis 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 thearia-currentis 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/PaginationNextalways have an accessible name (aria-label="Go to previous page"/"…next page"), so an icon-only rendering is still announced. Whendisabled,aria-disabled="true"is exposed and the missinghreftakes 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.PaginationEllipsishides only its glyph (aria-hiddenon theMoreHorizontal); thesr-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+Tabmove between them,Enteractivates a link. No roving tabindex — this is a list of links, not a composite widget. - Contrast:
ghostandoutlineare Button’s own treatments, unchanged —--foregroundtext on--background(near-maximal in both themes),--accent-foregroundon--accenton 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.