React Pagination Component Examples
Drop-in React Pagination examples: data-table footer, search results, windowed page list with ellipses, query-string routing, and rows-per-page. TypeScript.
Every list view eventually needs a page strip. The Pagination component in Drivn renders a horizontal <nav> with Previous, a list of numbered links, optional ellipses, and Next — six sub-components attached through dot notation to a single import. The component is fully stateless: it owns no current-page value, no totals, no event handlers. The call site decides which page is active and passes isActive to the matching Pagination.Link, and the component renders the styling. That split keeps Pagination reusable across server pagination, client-state pagination, and URL-driven pagination without baking one model into the package.
This page collects the patterns that ship with real tables and search results. Each example imports the component from @/components/ui/pagination — the path the Drivn CLI installs under — and uses the dot-notation API of Pagination, Pagination.Content, Pagination.Item, Pagination.Link, Pagination.Previous, Pagination.Next, and Pagination.Ellipsis so the whole tree composes through a single import. Every snippet is TypeScript because Drivn ships TypeScript-only.
The examples below cover the basic three-page strip, a windowed strip with ellipses for large totals, a Next.js search-parameter wiring, a "Rows per page" pairing with Select, and a pagination footer that sits under a Table. The full component reference and props table live on the Pagination docs page, and the shadcn/ui comparison sits on Drivn vs shadcn Pagination.
Basic three-page strip
The starting shape is Previous, three numbered links with the middle one active, and Next. Drop each piece into a Pagination.Item and the row spaces itself through flex flex-row items-center gap-1 from the styles.content class. The active link reads isActive and merges styles.active on top of styles.link, so the current page sits as a foreground-fill pill and the other two sit as muted-foreground text that brightens on hover.
This shape is the right starting point when the total page count is small enough that every page fits on the strip without an ellipsis — typically four or fewer pages. For larger totals jump to the windowed example in the next section. The href on each link should be a real URL like /products?page=2 rather than # so search engines crawl each page independently rather than collapsing the list under a single canonical. The href strings also let users open pages in a new tab through middle-click, which the # placeholder breaks. See Drivn vs shadcn Pagination for the markup parity that lets the same href pattern work in both libraries.
1 import { Pagination } from "@/components/ui/pagination" 2 3 export default function BasicPagination() { 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 }
Windowed strip with ellipses
For more than seven pages the strip starts to overflow on narrow viewports. The standard pattern is to render first, current minus one, current, current plus one, and last, with a Pagination.Ellipsis filling the gaps. The ellipsis sub-component renders a MoreHorizontal icon plus an sr-only text label so screen readers announce the gap. Compute the visible-page array once at the top of the parent component and map it to Pagination.Item children inside Pagination.Content.
The windowing logic is roughly ten lines of TypeScript and lives at the call site rather than inside the component — Drivn keeps Pagination purely presentational so the same component reuses across server pagination (where the current page comes from a searchParams prop) and client-state pagination (where the current page lives in useState). For very large totals — hundreds of pages — show an input field next to the strip so users can jump to a page number directly, since clicking through ellipses to reach page 87 is not realistic. Pair the input with the Drivn Input component and an onSubmit that updates the page state.
1 import { Pagination } from "@/components/ui/pagination" 2 3 export default function WindowedPagination() { 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.Ellipsis /> 23 </Pagination.Item> 24 <Pagination.Item> 25 <Pagination.Link href="?page=8">8</Pagination.Link> 26 </Pagination.Item> 27 <Pagination.Item> 28 <Pagination.Link href="?page=9">9</Pagination.Link> 29 </Pagination.Item> 30 <Pagination.Item> 31 <Pagination.Link href="?page=10">10</Pagination.Link> 32 </Pagination.Item> 33 <Pagination.Item> 34 <Pagination.Next href="?page=3" /> 35 </Pagination.Item> 36 </Pagination.Content> 37 </Pagination> 38 ) 39 }
Wiring to a Next.js search parameter
The cleanest URL pattern is ?page=2 — readable, shareable, and crawlable. Read the current page from useSearchParams in the parent component, parse it to a number with a sensible default, and pass it to a small builder function that returns the visible-page array. The href on every Pagination.Link should be a full search string like ?page=${n} so middle-click opens a new tab and the back button works without extra wiring. For server components, accept searchParams as a prop on the page function rather than calling the hook, since hooks only run in client components.
When the page lives inside a parent route with other query parameters — ?q=react&page=2&sort=popular — preserve the other params when building each link by spreading them into a URLSearchParams and overriding only the page key. The pattern keeps filters intact when users navigate the strip, which is the expected behavior on every list view that combines pagination with search or sort. See Drivn vs shadcn Pagination for the markup-parity story that means the same wiring works in both libraries with no API change.
1 'use client' 2 import { Pagination } from "@/components/ui/pagination" 3 import { useSearchParams } from "next/navigation" 4 5 export default function ProductsPagination({ 6 totalPages, 7 }: { 8 totalPages: number 9 }) { 10 const params = useSearchParams() 11 const current = Number(params.get('page') ?? '1') 12 13 const hrefFor = (page: number) => { 14 const next = new URLSearchParams(params) 15 next.set('page', String(page)) 16 return `?${next.toString()}` 17 } 18 19 return ( 20 <Pagination> 21 <Pagination.Content> 22 <Pagination.Item> 23 <Pagination.Previous href={hrefFor(Math.max(1, current - 1))} /> 24 </Pagination.Item> 25 {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( 26 <Pagination.Item key={page}> 27 <Pagination.Link 28 href={hrefFor(page)} 29 isActive={page === current} 30 > 31 {page} 32 </Pagination.Link> 33 </Pagination.Item> 34 ))} 35 <Pagination.Item> 36 <Pagination.Next href={hrefFor(Math.min(totalPages, current + 1))} /> 37 </Pagination.Item> 38 </Pagination.Content> 39 </Pagination> 40 ) 41 }
Rows-per-page picker
Pagination usually sits next to a "Rows per page" picker that lets the user trade more rows per screen for fewer total pages. Render the picker with the Drivn Select component on the left side of a flex row and the Pagination on the right side, both inside a flex items-center justify-between wrapper. The Select trigger reads the current page size from a state value, and changing the value resets the current page to 1 because a smaller rows-per-page means more pages overall.
This layout is the standard footer for an admin table or a search-results page. Keep the Select narrow — w-20 is enough for two-digit values — and pair it with a small static label like "Rows per page" to its left. On narrow viewports stack the picker and the strip vertically rather than side-by-side by switching the wrapper to flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between. The Select state and the pagination state both live in the same parent component, which gives a single source of truth for the data fetch that drives the table body. See the Data Table examples page for the row above the footer.
1 'use client' 2 import * as React from "react" 3 import { Pagination } from "@/components/ui/pagination" 4 import { Select } from "@/components/ui/select" 5 6 export default function TableFooter() { 7 const [pageSize, setPageSize] = React.useState('10') 8 const [page, setPage] = React.useState(1) 9 10 return ( 11 <div className="flex items-center justify-between"> 12 <div className="flex items-center gap-2 text-sm text-muted-foreground"> 13 Rows per page 14 <Select 15 value={pageSize} 16 onValueChange={(v) => { 17 setPageSize(v) 18 setPage(1) 19 }} 20 > 21 <Select.Trigger className="w-20"> 22 <Select.Value /> 23 </Select.Trigger> 24 <Select.Content> 25 <Select.Item value="10">10</Select.Item> 26 <Select.Item value="25">25</Select.Item> 27 <Select.Item value="50">50</Select.Item> 28 </Select.Content> 29 </Select> 30 </div> 31 <Pagination> 32 <Pagination.Content> 33 <Pagination.Item> 34 <Pagination.Previous href="#" /> 35 </Pagination.Item> 36 <Pagination.Item> 37 <Pagination.Link href="#" isActive> 38 {page} 39 </Pagination.Link> 40 </Pagination.Item> 41 <Pagination.Item> 42 <Pagination.Next href="#" /> 43 </Pagination.Item> 44 </Pagination.Content> 45 </Pagination> 46 </div> 47 ) 48 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
No. The component is purely presentational — it has no internal state, no event handlers, and no notion of total pages. The call site owns the current page (in useState, in useSearchParams, in route props for a Server Component) and passes isActive to whichever Pagination.Link represents that page. That split is intentional: server pagination, client-state pagination, and URL-driven pagination all use the same component without any API change, because the component never assumes where the page number lives.
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 the Pagination.Item entirely when page === 1), or render the Previous link with aria-disabled="true" and a pointer-events-none opacity-50 className. The conditional render is cleaner because it removes the affordance entirely; the aria-disabled pattern keeps the layout width constant which can look more stable when the strip is centered.
Yes. Edit the local @/components/ui/pagination.tsx file and replace the <a> elements inside Link, Previous, and Next with next/link imports. Because Drivn copies the source on install, the edit lives in your repo as a one-time change. The aria-current and aria-label attributes from the existing code continue to work because Next.js <Link> forwards every prop to the rendered anchor. The same edit applies to any other component where you want client-side routing on a link sub-component.
The Pagination.Ellipsis renders a <span> containing a MoreHorizontal lucide icon plus a <span className="sr-only">More pages</span> label. Screen readers skip the icon (it has no accessible name) and read "More pages" as the announced text, which signals to assistive tech that there is a gap in the page sequence without claiming a specific page number. The pattern matches the WAI-ARIA Authoring Practices guidance for visible-icon-with-hidden-label affordances.
Yes — use a real query string like ?page=2 rather than #. Real URLs let middle-click open a new tab, the back button restore the previous page, and search engines crawl each page independently. Pages reached only through JavaScript handlers are effectively invisible to crawlers and break common user behaviors. The Drivn Pagination.Link accepts any standard anchor href, and the wiring example earlier on this page shows how to build the href from a URLSearchParams that preserves other query parameters like search and sort.

