Pagination

A table-footer pagination bar with ellipsis page ranges (1 … 4 5 6 … 20), prev/next buttons that disable at bounds, a live X–Y of Z range readout, and a rows-per-page selector that opens upward. All state is internal — just pass totalItems. Inspired by Vercel.

Data TablesInspired by Vercel
Live PreviewInteractive
Vercel · Table pagination
NameEmailRoleStatus
Sarah Chen[email protected]AdminActive
Marcus Johnson[email protected]MemberActive
Priya Patel[email protected]ViewerInactive
Alex Rivera[email protected]MemberActive
Jordan Kim[email protected]AdminPending
Rows per page
110 of 198

Click pages or Prev/Next to navigate · Ellipsis compresses long ranges · Rows-per-page selector recomputes the readout

Pagination.tsx
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];

type Props = {
  totalItems: number;
  defaultPage?: number;
  defaultPageSize?: number;
};

export function Pagination({
  totalItems,
  defaultPage = 1,
  defaultPageSize = 10,
}: Props) {
  const [page, setPage] = useState(defaultPage);
  const [pageSize, setPageSize] = useState(defaultPageSize);
  const [sizeOpen, setSizeOpen] = useState(false);

  const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
  const p = Math.min(Math.max(page, 1), totalPages);
  const start = totalItems === 0 ? 0 : (p - 1) * pageSize + 1;
  const end = Math.min(p * pageSize, totalItems);

  const go = (n: number) => setPage(Math.max(1, Math.min(n, totalPages)));
  const changeSize = (s: number) => { setPageSize(s); setPage(1); setSizeOpen(false); };

  const getPageRange = (): (number | '...')[] => {
    if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
    const left = Math.max(2, p - 1);
    const right = Math.min(totalPages - 1, p + 1);
    const out: (number | '...')[] = [1];
    if (left > 2) out.push('...');
    for (let i = left; i <= right; i++) out.push(i);
    if (right < totalPages - 1) out.push('...');
    out.push(totalPages);
    return out;
  };

  return (
    <div className="flex items-center justify-between gap-4 px-4 py-3 bg-[#0a0a0a] border-t border-white/[0.06] select-none flex-wrap">
      {/* Rows per page */}
      <div className="relative flex items-center gap-2">
        <span className="text-[12px] text-zinc-500 whitespace-nowrap">Rows per page</span>
        <button
          onClick={() => setSizeOpen(o => !o)}
          className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#111] border border-[#1a1a1a] text-[12px] text-zinc-300 hover:border-[#2a2a2a] transition-colors"
        >
          {pageSize}
          <ChevronDown className="w-3 h-3 text-zinc-600" />
        </button>
        {sizeOpen && (
          <>
            <div className="fixed inset-0 z-40" onClick={() => setSizeOpen(false)} />
            <div className="absolute bottom-full mb-1.5 left-0 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 py-1 min-w-[80px]">
              {PAGE_SIZE_OPTIONS.map(s => (
                <button
                  key={s}
                  onClick={() => changeSize(s)}
                  className={cn(
                    'w-full text-left px-3 py-1.5 text-[12px] hover:bg-white/[0.06] transition-colors',
                    s === pageSize ? 'text-indigo-400' : 'text-zinc-300'
                  )}
                >
                  {s}
                </button>
              ))}
            </div>
          </>
        )}
      </div>

      {/* Range readout */}
      <span
        className="text-[12px] text-zinc-500 whitespace-nowrap"
        style={{ fontVariantNumeric: 'tabular-nums' }}
      >
        {start}–{end} of {totalItems}
      </span>

      {/* Page controls */}
      <div className="flex items-center gap-0.5">
        <button
          onClick={() => go(p - 1)}
          disabled={p === 1}
          aria-label="Previous page"
          className="p-1.5 rounded-md hover:bg-white/[0.06] disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-zinc-400"
        >
          <ChevronLeft className="w-3.5 h-3.5" />
        </button>
        {getPageRange().map((item, idx) =>
          item === '...' ? (
            <span key={'e' + idx} className="w-7 text-center text-[12px] text-zinc-600 select-none">

            </span>
          ) : (
            <button
              key={item}
              onClick={() => go(item as number)}
              className={cn(
                'w-7 h-7 rounded-md text-[12px] font-medium transition-colors',
                item === p ? 'bg-indigo-600 text-white' : 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200'
              )}
            >
              {item}
            </button>
          )
        )}
        <button
          onClick={() => go(p + 1)}
          disabled={p === totalPages}
          aria-label="Next page"
          className="p-1.5 rounded-md hover:bg-white/[0.06] disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-zinc-400"
        >
          <ChevronRight className="w-3.5 h-3.5" />
        </button>
      </div>
    </div>
  );
}