Skip to content

Pagination

Real anchors, a fixed-width page window and correctly disabled ends.

Navigationstarternavigationpagescollectionseo

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Pagination } from '@/components/ui/pagination'
import { DemoRow, DemoStage } from './_kit'

export default function PaginationDemo() {
  return (
    <DemoStage>
      <DemoRow label="Middle of a long set" description="The visible window never changes width.">
        <Pagination
          currentPage={7}
          totalPages={24}
          buildHref={(page) => `/components?page=${page}`}
          className="w-full"
        />
      </DemoRow>

      <DemoRow label="First page">
        <Pagination
          currentPage={1}
          totalPages={5}
          buildHref={(page) => `/components?page=${page}`}
          className="w-full"
        />
      </DemoRow>

      <DemoRow label="Last page">
        <Pagination
          currentPage={5}
          totalPages={5}
          buildHref={(page) => `/components?page=${page}`}
          className="w-full"
        />
      </DemoRow>

      <p className="text-xs text-ink-muted">
        Links are real anchors, so pages remain crawlable and work without JavaScript.
      </p>
    </DemoStage>
  )
}

components/demos/pagination.tsx

import { Pagination } from '@/components/ui/pagination'
import { DemoRow, DemoStage } from './_kit'

export default function PaginationDemo() {
  return (
    <DemoStage>
      <DemoRow label="Middle of a long set" description="The visible window never changes width.">
        <Pagination
          currentPage={7}
          totalPages={24}
          buildHref={(page) => `/components?page=${page}`}
          className="w-full"
        />
      </DemoRow>

      <DemoRow label="First page">
        <Pagination
          currentPage={1}
          totalPages={5}
          buildHref={(page) => `/components?page=${page}`}
          className="w-full"
        />
      </DemoRow>

      <DemoRow label="Last page">
        <Pagination
          currentPage={5}
          totalPages={5}
          buildHref={(page) => `/components?page=${page}`}
          className="w-full"
        />
      </DemoRow>

      <p className="text-xs text-ink-muted">
        Links are real anchors, so pages remain crawlable and work without JavaScript.
      </p>
    </DemoStage>
  )
}

components/ui/pagination.tsx

import Link from 'next/link'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Pagination
 *
 * Renders real anchors, so pages are crawlable, middle-clickable and work
 * without JavaScript. The window of visible page numbers is computed so the
 * control's width never depends on the total page count.
 */
export interface PaginationProps {
  currentPage: number
  totalPages: number
  /** Builds the href for a page number. */
  buildHref: (page: number) => string
  className?: string
  /** How many numbered links to show around the current page. */
  siblingCount?: number
  label?: string
}

function buildRange(current: number, total: number, siblings: number): Array<number | 'gap'> {
  const range: Array<number | 'gap'> = []
  const left = Math.max(2, current - siblings)
  const right = Math.min(total - 1, current + siblings)

  range.push(1)
  if (left > 2) range.push('gap')
  for (let page = left; page <= right; page += 1) range.push(page)
  if (right < total - 1) range.push('gap')
  if (total > 1) range.push(total)

  return range
}

export function Pagination({
  currentPage,
  totalPages,
  buildHref,
  className,
  siblingCount = 1,
  label = 'Pagination',
}: PaginationProps) {
  if (totalPages <= 1) return null

  const pages = buildRange(currentPage, totalPages, siblingCount)
  const hasPrevious = currentPage > 1
  const hasNext = currentPage < totalPages

  const itemClass =
    'inline-flex h-control min-w-[var(--density-control-height)] items-center justify-center rounded-md border px-2 text-sm font-medium transition-colors duration-150'

  return (
    <nav aria-label={label} className={cn('flex items-center justify-center gap-1', className)}>
      {hasPrevious ? (
        <Link
          href={buildHref(currentPage - 1)}
          rel="prev"
          className={cn(itemClass, 'border-line bg-surface text-ink hover:bg-surface-sunken')}
        >
          <ChevronLeft className="size-4" aria-hidden="true" />
          <span className="sr-only sm:not-sr-only sm:ml-1">Previous</span>
        </Link>
      ) : (
        <span
          aria-disabled="true"
          className={cn(itemClass, 'border-line-subtle text-ink-subtle opacity-60')}
        >
          <ChevronLeft className="size-4" aria-hidden="true" />
          <span className="sr-only sm:not-sr-only sm:ml-1">Previous</span>
        </span>
      )}

      <ol className="flex items-center gap-1">
        {pages.map((page, index) =>
          page === 'gap' ? (
            <li key={`gap-${index}`} aria-hidden="true" className="px-1 text-sm text-ink-subtle">
              …
            </li>
          ) : (
            <li key={page}>
              <Link
                href={buildHref(page)}
                aria-current={page === currentPage ? 'page' : undefined}
                className={cn(
                  itemClass,
                  page === currentPage
                    ? 'border-accent bg-accent text-accent-ink'
                    : 'border-line bg-surface text-ink hover:bg-surface-sunken',
                )}
              >
                {page}
                {page === currentPage ? <span className="sr-only">, current page</span> : null}
              </Link>
            </li>
          ),
        )}
      </ol>

      {hasNext ? (
        <Link
          href={buildHref(currentPage + 1)}
          rel="next"
          className={cn(itemClass, 'border-line bg-surface text-ink hover:bg-surface-sunken')}
        >
          <span className="sr-only sm:not-sr-only sm:mr-1">Next</span>
          <ChevronRight className="size-4" aria-hidden="true" />
        </Link>
      ) : (
        <span
          aria-disabled="true"
          className={cn(itemClass, 'border-line-subtle text-ink-subtle opacity-60')}
        >
          <span className="sr-only sm:not-sr-only sm:mr-1">Next</span>
          <ChevronRight className="size-4" aria-hidden="true" />
        </span>
      )}
    </nav>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For long collections where a user may need to return to a specific page. Rendering anchors rather than buttons keeps pages crawlable, middle-clickable and functional without JavaScript.

  • The visible window is computed, so the control never changes width between page 2 and page 200.
  • Disabled ends are rendered as spans with `aria-disabled`, not as focusable buttons that do nothing.
  • Previous and Next labels are hidden below `sm` but remain in the accessibility tree.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • First page
  • Middle with two ellipses
  • Last page
  • Configurable sibling count

Accessibility

Landmark
A labelled `<nav>` containing an ordered list of pages.
Current page
Marked with `aria-current="page"` plus a visually hidden ", current page".
Rel hints
`rel="prev"` and `rel="next"` are set for user agents that use them.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.