# Pagination (Caseroom component)

Page links with gaps: first, last, the current page and its neighbours; the ends turn off. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

38% of the 2,062 sites in The UI Index have a navigation (781 sites).

## Install

- React: `npx shadcn@latest add https://www.themes.dev/caseroom/r/pagination.json`
- Astro: `npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/pagination.json`
- HTML: https://www.themes.dev/caseroom/r/html/pagination.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/pagination/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### pagination.astro

```astro
---
import { cn } from '@/lib/utils';
import { NEXT_PATH, pageList, pagination, PREV_PATH } from './pagination.variants';

interface Props {
  current: number;
  total: number;
  /** The address of a page, e.g. (p) => `/blog/page/${p}/`. */
  href: (page: number) => string;
  label?: string;
  class?: string;
}

const { current, total, href, label = 'Pages', class: className } = Astro.props;
const pages = pageList(current, total);
const edge = (to: number, off: boolean) => (off ? { 'aria-disabled': 'true', role: 'link' } : { href: href(to) });
---

<nav aria-label={label} class={cn(pagination.root, className)}>
  <ul class={pagination.list}>
    <li>
      <a {...edge(current - 1, current <= 1)} rel="prev" class={pagination.link}>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={PREV_PATH} /></svg>
        <span class={pagination.edgeLabel}>Previous</span>
      </a>
    </li>
    {
      pages.map((p) => (
        <li class={pagination.number}>
          {p === null ? <span aria-hidden="true" class={pagination.gap}>…</span> : <a href={href(p)} aria-current={p === current ? 'page' : undefined} aria-label={`Page ${p}`} class={pagination.link}>{p}</a>}
        </li>
      ))
    }
    <li class={pagination.status}>Page {current} of {total}</li>
    <li>
      <a {...edge(current + 1, current >= total)} rel="next" class={pagination.link}>
        <span class={pagination.edgeLabel}>Next</span>
        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={NEXT_PATH} /></svg>
      </a>
    </li>
  </ul>
</nav>
```

### pagination.pages.ts

```tsx
/** The pages to show: the first, the last, the current one and one either side; null is a gap (…). */
export function pageList(current: number, total: number): (number | null)[] {
  const keep = new Set([1, total, current - 1, current, current + 1].filter((p) => p >= 1 && p <= total));
  const out: (number | null)[] = [];
  let last = 0;
  for (const p of [...keep].sort((a, b) => a - b)) {
    if (p - last === 2) out.push(last + 1);
    else if (p - last > 2) out.push(null);
    out.push(p);
    last = p;
  }
  return out;
}
```

### pagination.tsx

```tsx
import { cn } from '@/lib/utils';
import { NEXT_PATH, pageList, pagination, PREV_PATH } from './pagination.variants';

export interface PaginationProps {
  current: number;
  total: number;
  /** The address of a page, e.g. (p) => `/blog/page/${p}/`. */
  href: (page: number) => string;
  label?: string;
  className?: string;
}

/** Page links with gaps: the first, the last, the current page and one either side. */
export function Pagination({ current, total, href, label = 'Pages', className }: PaginationProps) {
  const pages = pageList(current, total);
  const edge = (to: number, off: boolean) => (off ? { 'aria-disabled': 'true' as const, role: 'link' } : { href: href(to) });
  return (
    <nav aria-label={label} className={cn(pagination.root, className)}>
      <ul className={pagination.list}>
        <li>
          <a {...edge(current - 1, current <= 1)} rel="prev" className={pagination.link}>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={PREV_PATH} /></svg>
            <span className={pagination.edgeLabel}>Previous</span>
          </a>
        </li>
        {pages.map((p, k) => (
          <li key={p ?? `gap-${k}`} className={pagination.number}>
            {p === null ? <span aria-hidden="true" className={pagination.gap}>…</span> : <a href={href(p)} aria-current={p === current ? 'page' : undefined} aria-label={`Page ${p}`} className={pagination.link}>{p}</a>}
          </li>
        ))}
        <li className={pagination.status}>
          Page {current} of {total}
        </li>
        <li>
          <a {...edge(current + 1, current >= total)} rel="next" className={pagination.link}>
            <span className={pagination.edgeLabel}>Next</span>
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={NEXT_PATH} /></svg>
          </a>
        </li>
      </ul>
    </nav>
  );
}
```

### pagination.variants.ts

```tsx
import { pageList } from './pagination.pages';

/** Page links: previous, numbers with gaps, next. The current page is aria-current; the ends are disabled links.
 *  It measures its own width (@container): under 32rem the numbers give way to 'Page 5 of 12' between the arrows. */
export const pagination = {
  root: 'w-full @container',
  list: 'm-0 flex list-none flex-wrap items-center gap-1 p-0',
  link: 'inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4',
  number: '@max-lg:hidden',
  status: 'inline-flex h-9 items-center px-2 text-sm text-muted-foreground tabular-nums @lg:hidden',
  gap: 'inline-flex h-9 min-w-6 items-center justify-center text-sm text-muted-foreground',
  edgeLabel: '@max-md:sr-only',
};
export { pageList };
export const PREV_PATH = 'M10 3.5L5.5 8l4.5 4.5';
export const NEXT_PATH = 'M6 3.5l4.5 4.5L6 12.5';
```
