Skip to content
Drivn
7 min read

Drivn vs shadcn/ui — Pagination Compared

Compare Drivn Pagination vs shadcn/ui — a stateless <nav> with pure className strings versus a Button-cva wrapper. Both copy-paste, one is leaner.

Drivn's Pagination and shadcn/ui's Pagination both render a horizontal page-strip — Previous, a list of numbered links, an optional ellipsis, Next — and both treat the component as a purely presentational <nav>. Neither library owns the current page state; you pass isActive to the link that represents the page you are on and the component renders the styling. That shared philosophy makes Pagination one of the rare surfaces where the two libraries look almost identical at the call site, which is exactly why the differences in the implementation file matter more than usual.

Drivn's Pagination ships as a single file at @/components/ui/pagination.tsx with a styles object that holds six class strings — nav, content, link, active, nav_link, and ellipsis — composed through the cn() utility. The Link component reads an isActive boolean prop and merges styles.active on top of styles.link when the page is current. There is no class-variance-authority, no Button wrapper, no Radix primitive, no client directive. The whole component renders on the server and the file is roughly one hundred and twenty lines of TypeScript over a native <nav>, <ul>, <li>, and <a> tree.

shadcn/ui's Pagination ships as a single file too, but it pulls in the project's Button component for its buttonVariants cva configuration. The PaginationLink reads a size prop and an isActive boolean and calls buttonVariants({ variant: isActive ? "outline" : "ghost", size }) to build the className. The dependency chain is shorter than for Dialog or NavigationMenu — no Radix at all — but it still binds Pagination to Button and to class-variance-authority. This page walks through every difference at the implementation level so the call-site similarity does not obscure the runtime story.

Side-by-side comparison

FeatureDrivnshadcn/ui
Underlying primitiveNative <nav> + <ul> + <a>Native <nav> + Button wrapper
Runtime UI dependencieslucide-react (Chevron + MoreHorizontal)lucide-react + Button + class-variance-authority
Class compositioncn() over a styles objectcva via buttonVariants()
Active state mechanismisActive prop merges styles.activeisActive prop selects variant in cva
aria-current="page"
"use client" directive
Server-renderable
Dot-notation APIPagination.Content, .Item, .Link, .Previous, .Next, .EllipsisPaginationContent, PaginationItem, PaginationLink, etc.
Bound to a Button componentYes — PaginationLink calls buttonVariants
Total component lines~130~120 (plus Button + cva)
LicenseMITMIT
Copy-paste install

The runtime footprint

Drivn's Pagination depends on lucide-react for three icons — ChevronLeft, ChevronRight, and MoreHorizontal — and on the local cn() utility at @/utils/cn. There is no class-variance-authority import, no shared Button wrapper, no Radix package. The component file declares a styles object with six class-string entries and renders each sub-component by merging the relevant entry through cn(styles.x, className). The whole tree is a stateless <nav role="navigation" aria-label="pagination"> with a <ul> child and <li> items — markup the browser already paints without any JavaScript at hydration time.

shadcn/ui's Pagination depends on the same three lucide icons, plus the project's Button component and class-variance-authority. The PaginationLink is implemented as <a className={cn(buttonVariants({ variant: isActive ? "outline" : "ghost", size }), className)} {...props} /> — a clean one-liner, but one that drags the Button cva configuration into the dependency graph. If a project already imports Button (almost every project does), this is no extra weight; if a project wanted Pagination without Button, the dependency is mandatory. The same trade-off appears in Drivn vs shadcn Button — Drivn keeps each component's class strings local instead of routing them through a shared variant function.

1// Drivn — styles object, no cva (verbatim from registry)
2const styles = {
3 nav: 'mx-auto flex w-full justify-center',
4 content: 'flex flex-row items-center gap-1',
5 link: cn(
6 'inline-flex items-center justify-center',
7 'h-9 min-w-9 px-3 rounded-[10px] text-sm font-medium',
8 'text-muted-foreground hover:text-foreground',
9 'hover:bg-accent transition-colors cursor-pointer'
10 ),
11 active: cn(
12 'bg-foreground text-background',
13 'hover:bg-foreground hover:text-background'
14 ),
15 nav_link: cn(
16 'inline-flex items-center justify-center',
17 'h-9 px-3 gap-1 rounded-[10px] text-sm font-medium',
18 'text-muted-foreground hover:text-foreground',
19 'hover:bg-accent transition-colors cursor-pointer'
20 ),
21 ellipsis: cn(
22 'flex h-9 w-9 items-center justify-center',
23 'text-muted-foreground'
24 ),
25}

How the active state is rendered

Both libraries lean on a single isActive boolean prop on the link sub-component. The styling path is where they split. Drivn's Link reads isActive and runs cn(styles.link, isActive && styles.active, className). The styles.active entry is two class strings — bg-foreground text-background plus a hover: repeat that locks the active background so the hover state does not invert mid-pagination. That is the entire active-state mechanism: an && short-circuit and a string append, evaluated at render time.

shadcn/ui's PaginationLink reads the same isActive prop and feeds it into buttonVariants({ variant: isActive ? "outline" : "ghost", size }). The cva function looks the value up in its variant and size maps, runs the lookups through clsx, and returns the className string. The advantage is that the active button styling now follows whatever the project's Button outline variant looks like — change the Button design, and Pagination inherits the change. The disadvantage is the coupling: a project that wants the pagination to use a foreground-fill active state (Drivn's default) has to either edit the Button outline variant globally or override the className at every active link. The Drivn default is to keep each component's active styling local so changes stay scoped.

1// Drivn — isActive merges styles.active on top of styles.link
2function Link({
3 isActive,
4 className,
5 ...props
6}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
7 isActive?: boolean
8}) {
9 return (
10 <a
11 aria-current={isActive ? 'page' : undefined}
12 className={cn(styles.link, isActive && styles.active, className)}
13 {...props}
14 />
15 )
16}

Accessibility and aria attributes

Both libraries set the same two aria attributes, and they set them in the same places. The root <nav> carries role="navigation" and aria-label="pagination" so assistive tech announces the region by purpose rather than position. The active link carries aria-current="page" so screen readers describe the current page within the strip. The Previous and Next links carry aria-label="Go to previous page" and aria-label="Go to next page" so the icon-only buttons announce their destination — both libraries spell the label as a full sentence rather than the curt "Previous" and "Next", which lines up with the WAI-ARIA Authoring Practices guidance.

The Ellipsis sub-component is where the libraries differ in markup nuance. Drivn renders a <span> with the MoreHorizontal lucide icon plus a <span className="sr-only">More pages</span> so screen readers announce the gap. shadcn/ui renders the same shape, also with an sr-only text label, but wraps it in a <span aria-hidden> to mark the icon explicitly as decorative. Both reach the same end state for assistive technology — the icon is decorative and the text label is announced. If a project already passes accessibility audits with the shadcn flavor, the Drivn flavor passes the same audits because the meaningful markup matches. See Drivn vs shadcn Button for a similar attribute-parity analysis on the surface that Pagination sits next to in most layouts.

Call-site shape and dot notation

Drivn uses dot notation throughout. A typical page strip reads <Pagination><Pagination.Content><Pagination.Item><Pagination.Previous href="#" />…</Pagination.Item></Pagination.Content></Pagination>. One import — import { Pagination } from "@/components/ui/pagination" — covers the root and every sub-component. The TypeScript signature on each sub-component is React.ComponentProps<'ul'> or React.AnchorHTMLAttributes<HTMLAnchorElement>, which means the full HTML prop surface comes through with autocomplete.

shadcn/ui uses kebab-cased named exports. The same strip reads <Pagination><PaginationContent><PaginationItem><PaginationPrevious href="#" />…</PaginationItem></PaginationContent></Pagination> and the import line lists every sub-component: import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis } from "@/components/ui/pagination". The longer import is the cost of the flat-export pattern shadcn uses across the library — it lines up with the Drivn vs shadcn Dropdown and Drivn vs shadcn Dialog split where Drivn collapses every sub-component into a Object.assign attached to the root.

1// Drivn — one import, dot notation
2import { Pagination } from "@/components/ui/pagination"
3
4export default function Page() {
5 return (
6 <Pagination>
7 <Pagination.Content>
8 <Pagination.Item>
9 <Pagination.Previous href="#" />
10 </Pagination.Item>
11 <Pagination.Item>
12 <Pagination.Link href="#">1</Pagination.Link>
13 </Pagination.Item>
14 <Pagination.Item>
15 <Pagination.Link href="#" isActive>
16 2
17 </Pagination.Link>
18 </Pagination.Item>
19 <Pagination.Item>
20 <Pagination.Next href="#" />
21 </Pagination.Item>
22 </Pagination.Content>
23 </Pagination>
24 )
25}

When each wins

Pick shadcn/ui's Pagination when the project already standardizes on buttonVariants for every interactive surface and the design system expects Pagination to inherit changes to the Button outline variant automatically. The cva binding is intentional — it keeps the rounded-corner radius, the focus ring, and the hover background consistent across Button, Pagination, and any other surface that opts into the same variant. For larger design systems with a strict shared variant table, this is the right call.

Pick Drivn's Pagination when the priority is keeping each component's styling local and shipping no extra dependencies past lucide icons. The styles object lives in the component file, the active-state merge is two cn() calls deep, and edits to the active background or the hover transition stay scoped to the Pagination file rather than rippling through the Button variant table. Pair it with the Drivn Table for the row above the strip and the Drivn Select for the "Rows per page" picker, and the whole data-table footer ships without any cva runtime in the bundle. See the Pagination examples page for the call-site shapes that pair with these surfaces.

Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

No. The component is fully stateless — every sub-component is a presentational wrapper over a native HTML element, and there is no useState, no useEffect, no event handler that touches browser APIs. The file at @/components/ui/pagination.tsx renders on the server, hydrates once, and stays inert through the rest of the page lifetime. Owning the current page state happens at the call site — typically in the parent page component or a useSearchParams hook — and that owner passes isActive to whichever Pagination.Link represents the current page.

The standard pattern is to read the current page from useSearchParams in the parent route, build the page-number array via Array.from({ length: totalPages }), and render each Pagination.Link with href={createPageHref(i + 1)} plus isActive={i + 1 === currentPage}. Because the component is server-renderable, you can run this in a Server Component and pass the array down to a small client wrapper only if the pagination needs to react to client-side filters. The href strings should be real URLs so search engines crawl every page rather than collapsing them under a single canonical URL.

Yes. The default Pagination.Link renders a native <a> tag with the styled className applied. To swap to client-side routing, edit the local @/components/ui/pagination.tsx file and replace the <a> element inside the Link function with import Link from "next/link" and <Link href={href} className={cn(styles.link, isActive && styles.active, className)} aria-current={isActive ? "page" : undefined} {...props}>. The same edit applies to Pagination.Previous and Pagination.Next. Because Drivn copies the source on install, the edit lives in your repo and survives package upgrades.

No — Drivn keeps the component presentational and leaves the page-number math to the call site. A small helper that builds the visible-page array (current, plus two neighbors on each side, plus first and last with ellipses between) is typically five to ten lines and lives in the parent route file. Treating Pagination as render-only keeps the same component reusable for offset pagination, cursor pagination, and infinite-scroll fallbacks without baking one assumption into the package. For an implementation reference see the windowing pattern on the Pagination examples page.

The styles.active entry is cn("bg-foreground text-background", "hover:bg-foreground hover:text-background"). The repeated hover: rules are intentional — without them, the link would inherit hover:bg-accent from styles.link and the active background would invert to the muted accent color when the user hovered the current page. Locking the hover state on the active link keeps the visual anchor steady while the user navigates the strip, which matches the Drivn principle of state changes that reinforce position rather than competing with it.