shadimage

Pagination

Responsive page navigation with ellipsis compression and disabled edges.

components/ui/pagination.tsx

import * as React from 'react'
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'

export interface PaginationProps extends React.HTMLAttributes<HTMLElement> {
  currentPage: number
  totalPages: number
  onPageChange: (page: number) => void
  siblingCount?: number
}

export function Pagination({ currentPage, totalPages, onPageChange, siblingCount = 1, className, ...props }: PaginationProps) {
  const pages = React.useMemo<(number | 'ellipsis')[]>(() => {
    const visibleCount = siblingCount * 2 + 5
    if (totalPages <= visibleCount) return Array.from({ length: totalPages }, (_, index) => index + 1)
    const left = Math.max(currentPage - siblingCount, 2)
    const right = Math.min(currentPage + siblingCount, totalPages - 1)
    const result: (number | 'ellipsis')[] = [1]
    if (left > 2) result.push('ellipsis')
    for (let page = left; page <= right; page += 1) result.push(page)
    if (right < totalPages - 1) result.push('ellipsis')
    result.push(totalPages)
    return result
  }, [currentPage, siblingCount, totalPages])

  const goTo = (page: number) => {
    if (page >= 1 && page <= totalPages && page !== currentPage) onPageChange(page)
  }

  return (
    <nav aria-label="Pagination" className={cn('font-sans', className)} {...props}>
      <div className="flex flex-wrap items-center gap-2">
        <button type="button" aria-label="Previous page" disabled={currentPage === 1} onClick={() => goTo(currentPage - 1)} className="flex h-10 items-center gap-1 rounded-full bg-rk-surface px-3 text-xs font-extrabold rk-border-sm rk-shadow-sm hover:bg-rk-primary focus:outline-none focus:ring-4 focus:ring-black focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-40">
          <ChevronLeft className="h-4 w-4 stroke-[3]" aria-hidden="true" /><span className="hidden sm:inline">Previous</span>
        </button>
        <div className="flex items-center gap-1" role="list">
          {pages.map((page, index) => page === 'ellipsis' ? (
            <span key={`ellipsis-${index}`} className="flex h-10 w-8 items-center justify-center text-black/45" aria-hidden="true"><MoreHorizontal className="h-4 w-4" /></span>
          ) : (
            <button key={page} type="button" role="listitem" aria-label={`Page ${page}`} aria-current={page === currentPage ? 'page' : undefined} onClick={() => goTo(page)} className={cn('flex h-10 min-w-10 items-center justify-center rounded-full px-3 text-sm font-black transition-colors focus:outline-none focus:ring-4 focus:ring-black focus:ring-offset-2', page === currentPage ? 'bg-rk-primary rk-border-sm rk-shadow-sm' : 'bg-rk-surface hover:bg-rk-mint')}>{page}</button>
          ))}
        </div>
        <button type="button" aria-label="Next page" disabled={currentPage === totalPages} onClick={() => goTo(currentPage + 1)} className="flex h-10 items-center gap-1 rounded-full bg-rk-surface px-3 text-xs font-extrabold rk-border-sm rk-shadow-sm hover:bg-rk-primary focus:outline-none focus:ring-4 focus:ring-black focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-40">
          <span className="hidden sm:inline">Next</span><ChevronRight className="h-4 w-4 stroke-[3]" aria-hidden="true" />
        </button>
      </div>
    </nav>
  )
}

No selection tags yet — empty means no evidence, not lack of support.

DependenciesBundle weight · light

icons
lucide-react