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.
A page strip is the last piece almost every list view needs, and it is the piece most libraries over-build. Drivn's Pagination goes the other way: it renders a <nav role="navigation" aria-label="pagination"> wrapping a <ul> of <li> items, and it holds no state whatsoever. No current page, no total count, no click handlers, no "use client" directive. Six sub-components hang off one import through dot notation — Content, Item, Link, Previous, Next, Ellipsis — and the only prop that changes appearance is isActive on Pagination.Link, which merges styles.active over styles.link and sets aria-current="page".
That statelessness is what makes the component reusable. Where the page number lives stays your decision: a searchParams prop in a Server Component, useSearchParams in a client component, or plain useState behind a client-side table. Pagination never forms an opinion, so none of those three call sites needs a different API or a wrapper to bridge the gap.
Every example below imports from @/components/ui/pagination, the path the Drivn CLI writes on install, and every snippet is TypeScript because Drivn ships TypeScript only. The patterns cover a small three-page strip, a windowed strip with ellipses for large totals, Next.js query-string wiring that preserves existing filters, a rows-per-page picker built with Select, and a table footer with a row-count summary under a Table. Props and the full reference live on the Pagination docs page; the competitor breakdown 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
Past seven or eight pages the strip stops fitting on a phone. The fix is a window: render page one, the current page with a neighbour on each side, the last page, and a Pagination.Ellipsis wherever the sequence jumps. The ellipsis sub-component renders a MoreHorizontal icon next to a <span className="sr-only">More pages</span> label, so a screen reader announces "More pages" at the gap rather than skipping silently past a decorative glyph.
Build the visible-page array once in the parent and map it into Pagination.Item children inside Pagination.Content. Keeping that arithmetic at the call site is deliberate — the component stays presentational, which is why the identical strip works whether the current page arrives from a searchParams prop, from useSearchParams, or from useState. Once totals reach the hundreds, add a jump-to-page field beside the strip using the Drivn Input component with an onSubmit that pushes the new page; clicking through ellipses to reach page 87 is not a path anyone actually takes. See Drivn vs shadcn Pagination for why the same windowing logic ports between the two libraries unchanged.
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.