Skip to contentVibraUI
Navigation & layout

Pagination

Page links with previous and next, for a list too long for one page.

Each page is a link: an anchor wearing Vibra's button recipe — ghost, with the current page outlined and marked aria-current="page" — where shadcn's base-nova renders Base UI's Button as the anchor, which gave every page role="button" and left a screen reader announcing a row of buttons. Previous and Next turn their arrows round and pad the arrow's own side in a right-to-left row. For a table's pager with a page size and a count, DataTable brings its own.

Install

npx shadcn@latest add @vibra/pagination

Needs the @vibra registry in your components.json — set it up once.

Examples

Previous and next only

A log paged by cursor has no total, so no numbers: newer and older at either end, the one with nothing past it marked unavailable.

Page counter

"Page 3 of 12" between two named arrows, as a polite status, where a row of numbers would crowd a card.

First and last page

480 pages of audit log: the ends are one click away, and on a phone the neighbours step aside.

Compact on a phone

Below sm the numbers give way to "4 of 12"; one of the two shows at a time, so the current page is met once.

With rows per page

An invoices table's footer: the page size, the range shown, and the arrows. A new size goes back to the first page.

Jump to a page

A typed page number, refused where it was typed when it doesn't exist, with the range and the focus kept in the field.

Server-rendered links

Every page a real URL with no state at all: it works before any script loads, opens in a new tab, and walks back with the browser.

Waits for the page

The rows dim under aria-busy, the pressed link turns into a spinner and keeps the focus, and a second press does nothing.

Right to left

Previous sits on the right and points right, back towards page 1; the arrows' padding follows them.

Props

PropTypeDefaultDescription
aria-labelstring"pagination"Names the navigation. Say which list it pages — "Invoice pages" — when a page has more than one.
PaginationLink.isActiveboolean—Marks the current page: the outline button and aria-current.
PaginationLink.size"icon" | "default" | "sm" | …"icon"The button size the link wears. A three-digit page widens with its className.
PaginationPrevious.text / PaginationNext.textstring"Previous" / "Next"The words beside the arrow, shown from sm up. Pass an aria-label to match them: "Newer events".
aria-disabledboolean—On a link with nowhere to go: it dims and says so, and stays in its place. Stop its click in the handler.

Dependencies

Source

components/ui/pagination.tsx
import * as React from "react"
import { cn } from "@/lib/utils"

import { buttonVariants, type ButtonProps } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"

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 items-center gap-0.5", className)}
      {...props}
    />
  )
}

function PaginationItem({ ...props }: React.ComponentProps<"li">) {
  return <li data-slot="pagination-item" {...props} />
}

type PaginationLinkProps = {
  isActive?: boolean
} & Pick<ButtonProps, "size"> &
  React.ComponentProps<"a">

/**
 * A page is a place, so each one is a link: an anchor that wears the button
 * recipe, the way shadcn's Radix pagination draws it. Rendering Base UI's
 * Button as the anchor instead gave every page `role="button"`, and a
 * screen reader announced a row of buttons that never said where they went.
 */
function PaginationLink({
  className,
  isActive,
  size = "icon",
  ...props
}: PaginationLinkProps) {
  return (
    <a
      aria-current={isActive ? "page" : undefined}
      data-slot="pagination-link"
      data-active={isActive || undefined}
      data-size={size}
      className={cn(
        buttonVariants({ variant: isActive ? "outline" : "ghost", size }),
        className
      )}
      {...props}
    />
  )
}

function PaginationPrevious({
  className,
  text = "Previous",
  ...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
  return (
    <PaginationLink
      aria-label="Go to previous page"
      size="default"
      // The arrow turns and the padding follows it in a right-to-left row,
      // where "previous" sits on the right and points right.
      className={cn("ps-1.5!", className)}
      {...props}
    >
      <ChevronLeftIcon data-icon="inline-start" className="rtl:rotate-180" />
      <span className="hidden sm:block">{text}</span>
    </PaginationLink>
  )
}

function PaginationNext({
  className,
  text = "Next",
  ...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
  return (
    <PaginationLink
      aria-label="Go to next page"
      size="default"
      className={cn("pe-1.5!", className)}
      {...props}
    >
      <span className="hidden sm:block">{text}</span>
      <ChevronRightIcon data-icon="inline-end" className="rtl:rotate-180" />
    </PaginationLink>
  )
}

function PaginationEllipsis({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden
      data-slot="pagination-ellipsis"
      className={cn(
        "flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <MoreHorizontalIcon
      />
      <span className="sr-only">More pages</span>
    </span>
  )
}

export {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
}