Pagination
An accessible pagination nav with previous/next, page links and ellipsis, styled with the ai2 button variants; the current page gets aria-current.
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
export default function PaginationDemo() {
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="?page=1" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=1">1</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=2" isActive>
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/paginationDependencies, the @ai2/tokens theme and the component file are installed together. The @ai2/button component installs alongside - pagination links reuse its variants.
Install dependencies
npm install lucide-react@^1.23.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/pagination.tsx"use client"
import type * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem(props: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
interface PaginationLinkProps extends React.ComponentProps<"a"> {
isActive?: boolean
size?: "sm" | "md"
}
function PaginationLink({ className, isActive, size = "md", ...props }: PaginationLinkProps) {
return (
<a
data-slot="pagination-link"
aria-current={isActive ? "page" : undefined}
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
tone: "neutral",
size,
}),
"min-w-9 px-3",
className
)}
{...props}
/>
)
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
className={cn("gap-1 ps-2.5", className)}
{...props}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
className={cn("gap-1 pe-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRight className="size-4" />
</PaginationLink>
)
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}Pagination imports buttonVariants from components/ui/button.tsx - copy the Button component as well. Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the tone colors will be missing.
Usage
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="?page=1" />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=1" isActive>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=3" />
</PaginationItem>
</PaginationContent>
</Pagination>Render your page numbers into PaginationLink and set isActive on the current one - it gets the outline button style and aria-current="page".
Examples
Sizes
Both values of the size axis: sm and md (the default), mapped to the ai2 button sizes. The active page shows the outline style in each.
Previous and next only
The previous/next labels hide on small screens, leaving only the chevrons.
Props
PaginationLink (and PaginationPrevious/ PaginationNext, which extend it) render a native <a> and accept all its props; the other parts accept the native props of their <nav>, <ul>, <li> and <span> elements.
PaginationLink props
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | - | Marks the link as the current page - renders the outline button style and sets aria-current="page". |
size | "sm" | "md" | "md" | Button size used for the link. PaginationPrevious and PaginationNext accept it too. |
ai2 Pagination: link-based page navigation for React
The ai2 Pagination is a shadcn-compatible pagination component for React, styled with Tailwind CSS v4 on the shared ai2 tokens. It renders page navigation as real links inside a labeled nav landmark: previous and next controls, numbered page links and an ellipsis for collapsed ranges, all styled by reusing the ai2 button variants so pagination matches your buttons automatically.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Pagination?
It is a seven-part composition: Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext and PaginationEllipsis. The anatomy matches shadcn/ui exactly, so existing snippets and AI agents keep working without changes.
There is no radix primitive here; the accessibility comes from semantic HTML. The root is a nav with aria-label="pagination", links are real anchor elements, the active page carries aria-current="page", and the ellipsis is hidden from assistive technology with an sr-only "More pages" text in its place.
Why use it
- Real links, real navigation: Page controls render anchor elements, so they work with URLs, open in new tabs, get crawled by search engines and integrate with router link components via the usual props.
- Current page marked for everyone: isActive sets aria-current="page" and data-active, so screen readers announce the current page and CSS can target it, while the outline style shows it visually.
- Styled by your buttons: Links reuse buttonVariants (ghost for idle pages, outline for the active one, neutral tone), so pagination automatically matches every ai2 button in your app.
- Navigation landmark built in: The root nav carries role="navigation" and aria-label="pagination", so assistive technology lists it as a distinct landmark on the page.
- Agent-readable metadata: The registry item describes the parts and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds pagination, its @ai2/button dependency and the @ai2/tokens theme to your project.
- Two sizes: PaginationLink, PaginationPrevious and PaginationNext accept size="sm" or "md" (the default), mapped to the ai2 button sizes.
- Responsive previous and next: The Previous and Next text labels hide below the sm breakpoint, leaving chevron-only controls on narrow screens with aria-labels intact.
- Accessible ellipsis: PaginationEllipsis is aria-hidden with an sr-only "More pages" text, so collapsed ranges read correctly to screen readers.
- Data attributes for styling: Every part exposes data-slot, and the active link adds data-active, so you can restyle states from CSS without forking the component.
- TypeScript source: The installed file is typed end to end; links accept every native anchor prop plus isActive and size.
Production tips
- Set isActive on exactly one link: The current page should be the only link with isActive, giving one aria-current="page" per pagination nav. Screen readers rely on that single marker.
- Use real hrefs, not click handlers: Point each link at the actual page URL (?page=3 or /blog/3). Middle-click, open in new tab and SEO crawling all depend on genuine hrefs.
- Swap in your router's Link: PaginationLink renders a plain anchor. For client-side navigation, pass your framework's link behavior through, or copy the source and swap the anchor for your router's Link component; you own the file.
- Collapse long ranges with the ellipsis: Show the first page, a window around the current page and the last page, with PaginationEllipsis between. Rendering fifty numbered links helps nobody.
- Disable edges deliberately: On the first or last page, either omit Previous/Next or render them with aria-disabled and no href, so keyboard users do not tab into dead links.
- Note the client boundary: The file starts with "use client" because it imports buttonVariants from the client-marked button module. It renders fine from server components; the boundary is already declared for you.
Works with the rest of ai2
Pagination usually sits under a data surface. Pair it with an ai2 Table for paged result sets, with ai2 Card grids for catalog and blog listings, and with ai2 Skeleton rows while the next page loads.
Because the links are styled by Button variants, any theming you apply to buttons carries over to pagination for free. One token source keeps the whole listing page consistent in both light and dark mode.