How to Add Pagination to a React App
Step-by-step guide to adding a pagination bar to any React project with the Drivn CLI — stateless native anchor links, zero runtime dependencies.
Any React list that grows past a screenful eventually needs a pagination bar — the row of numbered links with Previous and Next that lets people move through pages of results. Building one by hand is fiddlier than it looks: the markup wants correct aria-current and aria-label attributes, the active page needs a distinct style, and long ranges need an ellipsis so the bar does not run off the screen. Most teams either ship an accessibility-thin version or pull in a table library far heavier than the control they actually needed.
Drivn takes a lighter route: the CLI copies a pagination.tsx file straight into your repository, and the component holds no state at all. The Pagination is a small family of functions joined with Object.assign — the root <nav> plus .Content, .Item, .Link, .Previous, .Next, and .Ellipsis. Every clickable element is a native <a href> styled from one styles object, and the isActive prop marks the current page with aria-current="page". There is no 'use client' directive and no useState inside the component — the call site owns the current page.
This guide adds the bar to any React app — install the CLI, render the row, drive the active page from your own state, and truncate long ranges. It works the same in Vite + React or a Next.js project. For the App Router walkthrough see the Next.js Pagination guide.
Prerequisites
Before installing Pagination, confirm your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your styles, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired. For a custom setup, check the compilerOptions.paths entry in tsconfig.json; the installation page lists the minimal config. The Pagination imports the ChevronLeft, ChevronRight, and MoreHorizontal glyphs from lucide-react for the Previous, Next, and ellipsis controls, so keep that package installed — those icons and the cn utility are its only imports beyond React.
Step 1 — Install Drivn via the CLI
Run the CLI from your project root to add the Pagination source file. The command prompts once for your install directory (defaulting to src/components/ui/), writes pagination.tsx, and finishes. No global config file is created; the result is one TypeScript file you edit like any other component. Confirm it landed in your editor, then commit it for a clean baseline. If your project uses a monorepo layout or a non-standard source path, the CLI docs cover the flags for targeting a custom location during install. When the list above the bar is a sortable grid, add the Drivn Data Table alongside it with the same command.
1 # add the Pagination source file 2 npx drivn add pagination
Step 2 — Render the pagination bar
Import Pagination from your UI directory and render the bar. The root renders a <nav aria-label="pagination">, Pagination.Content renders the <ul> row, and each Pagination.Item wraps a single control — a Pagination.Previous, a numbered Pagination.Link, or a Pagination.Next. Mark the current page by passing isActive to its Pagination.Link, which applies the styles.active classes and sets aria-current="page" so assistive technology announces the position. Because every control is a native <a href> and the component holds no state, the file carries no 'use client' directive — it renders anywhere React reaches the DOM, including a Server Component. Give each link a real href like ?page=2 rather than # so the browser can bookmark it and a middle-click opens a new tab.
1 import { Pagination } from '@/components/ui/pagination' 2 3 export function ListPager() { 4 return ( 5 <Pagination> 6 <Pagination.Content> 7 <Pagination.Item> 8 <Pagination.Previous href="?page=1" /> 9 </Pagination.Item> 10 <Pagination.Item> 11 <Pagination.Link href="?page=1">1</Pagination.Link> 12 </Pagination.Item> 13 <Pagination.Item> 14 <Pagination.Link href="?page=2" isActive> 15 2 16 </Pagination.Link> 17 </Pagination.Item> 18 <Pagination.Item> 19 <Pagination.Link href="?page=3">3</Pagination.Link> 20 </Pagination.Item> 21 <Pagination.Item> 22 <Pagination.Next href="?page=3" /> 23 </Pagination.Item> 24 </Pagination.Content> 25 </Pagination> 26 ) 27 }
Step 3 — Drive the active page from state
In a client-only React app the current page usually lives in useState, and each link toggles it rather than navigating away. Because Pagination is presentational, this wiring lives entirely at your call site: hold page in state, build a number range from your total count with Array.from, and set isActive={p === page} on each link so the highlight tracks state. Render each Pagination.Link with an onClick that calls setPage(p) and keep a href for the no-JavaScript fallback, then derive the visible slice of your data from page and a page size. The component never assumes where the page number lives, so the same markup serves URL-driven pagination in Next.js and state-driven pagination in a single-page app without any API change.
1 'use client' 2 import * as React from 'react' 3 import { Pagination } from '@/components/ui/pagination' 4 5 export function StatePager({ totalPages }: { totalPages: number }) { 6 const [page, setPage] = React.useState(1) 7 const pages = Array.from({ length: totalPages }, (_, i) => i + 1) 8 return ( 9 <Pagination> 10 <Pagination.Content> 11 <Pagination.Item> 12 <Pagination.Previous 13 href="#" 14 onClick={() => setPage((p) => Math.max(1, p - 1))} 15 /> 16 </Pagination.Item> 17 {pages.map((p) => ( 18 <Pagination.Item key={p}> 19 <Pagination.Link 20 href="#" 21 isActive={p === page} 22 onClick={() => setPage(p)} 23 > 24 {p} 25 </Pagination.Link> 26 </Pagination.Item> 27 ))} 28 <Pagination.Item> 29 <Pagination.Next 30 href="#" 31 onClick={() => setPage((p) => Math.min(totalPages, p + 1))} 32 /> 33 </Pagination.Item> 34 </Pagination.Content> 35 </Pagination> 36 ) 37 }
Step 4 — Truncate long ranges and restyle
When a list runs to dozens of pages you do not render every link — show the first pages, an ellipsis, and the last pages. Drop a Pagination.Ellipsis into a Pagination.Item in place of the numbers you want to skip; it renders a MoreHorizontal glyph plus an sr-only "More pages" label from the styles.ellipsis class, so the gap reads correctly to a screen reader. Everything visual lives in one styles object at the top of the file you own — styles.link for the resting number, styles.active (bg-foreground text-background) for the current page, and styles.nav_link for Previous and Next. Because those classes read Tailwind tokens like bg-accent and text-muted-foreground, changing them in theming re-themes the whole bar for dark and light mode at once. For copy-paste ranges and layouts see the Pagination examples.
1 // styles.active — current-page highlight (verbatim from pagination.tsx) 2 active: cn( 3 'bg-foreground text-background', 4 'hover:bg-foreground hover:text-background' 5 ),
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
Frequently asked questions
Yes. Set the project up with TypeScript and Tailwind v4 first, then run npx drivn add pagination. The component has no dependency on Next.js or any router — every control is a native <a href>, so it works anywhere React and Tailwind reach the DOM. Its only imports beyond React and the cn utility are the ChevronLeft, ChevronRight, and MoreHorizontal icons from lucide-react, so the CLI writes a single pagination.tsx file with nothing else to configure after install.
No. The component is purely presentational — it has no useState, no event handlers, and no notion of total pages. The call site owns the current page, whether that lives in useState, in a URL search parameter, or in server route props, and passes isActive to whichever Pagination.Link represents that page. That split is intentional: state-driven, URL-driven, and server-rendered pagination all use the same markup without any API change.
Pass the isActive prop to the Pagination.Link whose number matches the current page. It applies the styles.active classes — bg-foreground text-background — for the highlight and sets aria-current="page" so assistive technology announces which page is active. Drive it by comparing each link's number to the page value your call site owns, for example isActive={p === page}. Because the marker is derived from that value rather than from any component-internal state, the highlight always matches the page you rendered.
The default Pagination.Previous is a plain <a> tag, which does not respect a disabled prop the way a <button> would. Two patterns work: render the Previous link conditionally (skip its Pagination.Item entirely when the page is 1), or render it with aria-disabled="true" and a pointer-events-none opacity-50 className. The conditional render is cleaner because it removes the affordance; the aria-disabled pattern keeps the layout width constant, which looks steadier when the bar is centered.
Render a Pagination.Ellipsis inside a Pagination.Item in place of the page numbers you want to skip. It draws a MoreHorizontal icon with an sr-only "More pages" label from the styles.ellipsis class, so the truncation reads correctly to screen readers. A common shape is first page, ellipsis, the pages around the current one, ellipsis, last page. Compute which numbers to show from the current page and the total, then render the surrounding numbers as Pagination.Link items and the gaps as Pagination.Ellipsis.

