# Pagination

> Move through a collection that is split into numbered pages.

Source: https://www.honestui.com/docs/components/pagination

```tsx
"use client";

import * as React from "react";

import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/honest-ui/ui/pagination";

export function PaginationDemo() {
  const [page, setPage] = React.useState(2);
  const pageHref = (nextPage: number) =>
    `/docs/components/pagination?page=${nextPage}`;
  const navigate =
    (nextPage: number) => (event: React.MouseEvent<HTMLAnchorElement>) => {
      event.preventDefault();
      setPage(nextPage);
    };

  return (
    <Pagination aria-label="Project results">
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious
            aria-disabled={page === 1}
            href={pageHref(Math.max(1, page - 1))}
            onClick={page === 1 ? undefined : navigate(page - 1)}
          />
        </PaginationItem>
        {[1, 2, 3].map((item) => (
          <PaginationItem key={item}>
            <PaginationLink
              href={pageHref(item)}
              isActive={page === item}
              onClick={navigate(item)}
            >
              {item}
            </PaginationLink>
          </PaginationItem>
        ))}
        <PaginationItem>
          <PaginationEllipsis />
        </PaginationItem>
        <PaginationItem>
          <PaginationNext
            aria-disabled={page === 3}
            href={pageHref(Math.min(3, page + 1))}
            onClick={page === 3 ? undefined : navigate(page + 1)}
          />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  );
}

```

## Overview [#overview]

Use Pagination when a long collection is split into discrete, addressable pages: search results, tables, audit logs, directories. Numbered pages give people a mental map of the whole set ("128 results, I'm on page 3") and direct access to specific positions — two things infinite scroll cannot provide.

Build every page link from the real result count and keep the current page both visibly and programmatically marked. See [Don't do this](#dont-do-this) for the mistakes that break that contract.

## Anatomy [#anatomy]

Pagination includes previous and next controls, numbered page links wrapped in list items, an ellipsis for skipped ranges, and the marked current page. It renders a `<nav>` landmark labeled "pagination", so screen-reader users can jump to it directly. `PaginationLink` styles itself as a ghost button and switches to the outline treatment when active; Previous and Next collapse to icon-only buttons on small screens.

## Usage guidance [#usage-guidance]

Prefer links over click handlers: real `href` values mean every page is openable, copyable, shareable, and works before hydration. Show enough neighboring numbers for people to step locally (typically one or two each side) and use `PaginationEllipsis` to compress distant ranges rather than rendering fifty links.

Disable or omit Previous and Next only at the true boundaries of the collection. On the first page there is no previous page; pretending otherwise sends people to a broken state. The component has no built-in disabled styling for these controls — mark boundaries with `aria-disabled` and drop the click handler, as the demo does.

## Accessibility [#accessibility]

The wrapper emits `<nav aria-label="pagination">`, and `PaginationLink` sets `aria-current="page"` whenever you pass `isActive`, so assistive technology announces "current page" instead of just reading another number. That attribute is the entire mechanism for communicating position — never fake it with styling alone.

Previous and Next carry built-in accessible names ("Go to previous page", "Go to next page"), which matter most when they collapse to icons on small screens. `PaginationEllipsis` hides its dots from assistive technology and announces "More pages" so skipped ranges are not read as silence.

Links show visible focus rings through their button styling. Colors come from theme tokens, so active, hover, and default states adapt to dark mode automatically. Page-number targets are button-sized; leave extra spacing if pagination is a primary touch control on mobile.

There is no loading state: render your own progress feedback near the collection while new pages fetch.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;pagination&#x22;]" />
  

  
    
      
        Copy and paste the following code into your project.
      

      ### components/ui/pagination.tsx

```tsx
import * as React from "react"
import { mergeProps } from "@base-ui-components/react/merge-props"
import { useRender } from "@base-ui-components/react/use-render"
import {
  ChevronLeft as ChevronLeftIcon,
  ChevronRight as ChevronRightIcon,
  Ellipsis as MoreHorizontalIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/honest-ui/ui/button"

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(
        "m-0 flex list-none flex-row items-center gap-1 p-0",
        className
      )}
      {...props}
    />
  )
}

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

type PaginationLinkProps = {
  isActive?: boolean
  size?: React.ComponentProps<typeof Button>["size"]
} & useRender.ComponentProps<"a">

function PaginationLink({
  className,
  isActive,
  size = "icon",
  render,
  ...props
}: PaginationLinkProps) {
  const defaultProps = {
    "aria-current": isActive ? ("page" as const) : undefined,
    "data-slot": "pagination-link",
    "data-active": isActive,
    className: render
      ? className
      : cn(
          buttonVariants({
            variant: isActive ? "outline" : "ghost",
            size,
          }),
          className
        ),
  }

  return useRender({
    defaultTagName: "a",
    render,
    props: mergeProps<"a">(defaultProps, props),
  })
}

function PaginationPrevious({
  className,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="Go to previous page"
      size="default"
      className={cn("max-sm:aspect-square max-sm:p-0", className)}
      {...props}
    >
      <ChevronLeftIcon className="sm:-ms-1" />
      <span className="max-sm:hidden">Previous</span>
    </PaginationLink>
  )
}

function PaginationNext({
  className,
  ...props
}: React.ComponentProps<typeof PaginationLink>) {
  return (
    <PaginationLink
      aria-label="Go to next page"
      size="default"
      className={cn("max-sm:aspect-square max-sm:p-0", className)}
      {...props}
    >
      <span className="max-sm:hidden">Next</span>
      <ChevronRightIcon className="sm:-me-1" />
    </PaginationLink>
  )
}

function PaginationEllipsis({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden
      data-slot="pagination-ellipsis"
      className={cn("flex min-w-7 justify-center", className)}
      {...props}
    >
      <MoreHorizontalIcon className="size-4" />
      <span className="sr-only">More pages</span>
    </span>
  )
}

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

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
```

```tsx
<Pagination>
  <PaginationContent>
    <PaginationItem>
      <PaginationPrevious href="?page=1" />
    </PaginationItem>
    <PaginationItem>
      <PaginationLink href="?page=1" isActive>
        1
      </PaginationLink>
    </PaginationItem>
    <PaginationItem>
      <PaginationEllipsis />
    </PaginationItem>
    <PaginationItem>
      <PaginationNext href="?page=2" />
    </PaginationItem>
  </PaginationContent>
</Pagination>
```

Pass `render={<Link href="..." />}` to compose with your router while keeping pagination styling. Override the landmark label per instance with `aria-label` when several paginated collections share a view.

## Don't do this [#dont-do-this]

### Paging with onClick buttons [#paging-with-onclick-buttons]

```tsx
// Bad
<PaginationLink onClick={() => setPage(2)}>2</PaginationLink>
```

```tsx
// Good
<PaginationLink href="?page=2" onClick={softNavigate}>2</PaginationLink>
```

Without an `href`, a page number is not a link: it cannot be opened in a new tab, copied, or revisited after refresh, and the URL no longer describes what is on screen. Keep the `href` as the source of truth and intercept the click only to avoid a full reload.

### Omitting the current-page marker [#omitting-the-current-page-marker]

```tsx
// Bad
{pages.map((p) => (
  <PaginationLink key={p} href={`?page=${p}`}>{p}</PaginationLink>
))}
```

```tsx
// Good
<PaginationLink href={`?page=${page}`} isActive>{page}</PaginationLink>
```

Styling alone does not travel: without `isActive`, no `aria-current="page"` is emitted and screen-reader users hear an unordered list of identical numbers with no way to know where they are. Mark exactly one link per pagination control.

### Hard-coding the page count [#hard-coding-the-page-count]

```tsx
// Bad
{[1, 2, 3].map((p) => (
  <PaginationLink key={p} href={`?page=${p}`} isActive={p === page}>
    {p}
  </PaginationLink>
))}
```

```tsx
// Good
{lastPage > 3 && <PaginationEllipsis />}
{/* Render pages from total count / pageSize */}
```

A fixed `[1, 2, 3]` breaks silently the day the collection grows past three pages: pages four and beyond become unreachable even though Next keeps promising them. Derive the range from the actual item count, and use the ellipsis once the honest range no longer fits.

## Examples [#examples]

### Results with page position [#results-with-page-position]

State which slice is visible above the controls so people can orient before choosing a page.

```tsx
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
} from "@/components/honest-ui/ui/pagination";

export function PaginationResults() {
  return (
    <div className="grid gap-3 text-center">
      <p className="text-sm text-muted-foreground">Showing 21-30 of 128</p>
      <Pagination>
        <PaginationContent>
          <PaginationItem>
            <PaginationLink href="/docs/components/pagination?page=2">
              2
            </PaginationLink>
          </PaginationItem>
          <PaginationItem>
            <PaginationLink href="/docs/components/pagination?page=3" isActive>
              3
            </PaginationLink>
          </PaginationItem>
          <PaginationItem>
            <PaginationEllipsis />
          </PaginationItem>
        </PaginationContent>
      </Pagination>
    </div>
  );
}

```

### Compact controls [#compact-controls]

Numbered links only, for short collections where Previous and Next add noise.

```tsx
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
} from "@/components/honest-ui/ui/pagination";

export function PaginationCompact() {
  return (
    <Pagination>
      <PaginationContent>
        {[1, 2, 3, 4].map((page) => (
          <PaginationItem key={page}>
            <PaginationLink
              href={`/docs/components/pagination?page=${page}`}
              isActive={page === 3}
            >
              {page}
            </PaginationLink>
          </PaginationItem>
        ))}
      </PaginationContent>
    </Pagination>
  );
}

```

### Previous and next only [#previous-and-next-only]

For linear browsing where jumping to arbitrary pages has no value.

```tsx
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationNext,
  PaginationPrevious,
} from "@/components/honest-ui/ui/pagination";

export function PaginationMini() {
  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          <PaginationPrevious href="/docs/components/pagination?page=2" />
        </PaginationItem>
        <PaginationItem>
          <PaginationNext href="/docs/components/pagination?page=4" />
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  );
}

```

## API reference [#api-reference]

All parts forward their matching native element props. `Pagination` renders a `<nav aria-label="pagination">`; `PaginationContent` renders a `<ul>`; `PaginationItem` renders an `<li>`; `PaginationEllipsis` renders an `aria-hidden` span with a visually hidden "More pages" label.

| Prop       | Component                        | Values                              | Default  |
| ---------- | -------------------------------- | ----------------------------------- | -------- |
| `isActive` | `PaginationLink`                 | boolean                             | `false`  |
| `size`     | `PaginationLink`                 | Button sizes (`icon`, `default`, …) | `"icon"` |
| `render`   | `PaginationLink`, Previous, Next | Base UI `render` element            | —        |

`PaginationLink` emits `aria-current="page"` and `data-active` when `isActive` is true, and styles itself via Button variants: `ghost` normally, `outline` when active. `PaginationPrevious` and `PaginationNext` are preset links with "Go to previous page" and "Go to next page" labels plus chevron icons; they hide their text below the `sm` breakpoint.
