v1.0

Pagination

Preview

Basic

Loading…

Preview

Placement and navigation

Loading…

Preview

Composed from parts

Loading…

Preview

Code

ts
import Pagination from '@/components/table/Pagination';

src/components/table/Pagination.tsx

AI prompt

text
Build a pagination component in React + TypeScript + Tailwind CSS: numbered page links with ellipsis gaps (or a "page N of M" box), first / prev / next / last steppers, a range line and a rows-per-page select — as a props-driven component with three placements, and as composable parts for custom layouts.

## Look
- Everything `text-xs`. Three placements (`variant`), differing only in the wrapper:
  - `bar` (default): full width under a table — `flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-t border-slate-200 dark:border-slate-800 px-4 py-2`.
  - `pill`: centred in flow — `mt-3 mx-auto w-fit flex items-center gap-4 rounded-full border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-4 py-2 shadow-lg`.
  - `floating`: the same pill `fixed bottom-4 left-1/2 -translate-x-1/2 z-20`.
- Left: the range line, slate-600 / dark slate-300 — "**1**–**25** of **1204** members" (numbers semibold). In the pill variants it hides below `sm`.
- Right (`flex items-center gap-3`): a "Rows" label with a small native select (`rounded-md border border-slate-200 dark:border-slate-700 bg-transparent px-1.5 py-0.5`), then the controls in `flex items-center gap-0.5`.
- Steppers: ChevronsLeft / ChevronLeft / ChevronRight / ChevronsRight at 14px, `p-1.5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg`, disabled `opacity-40` with no hover. In pill variants `rounded-full`, dark hover slate-700, and they press in (`active:scale-90`).
- Page links: `min-w-[1.75rem] px-1.5 py-1 rounded-lg` (`rounded-full` in pills), slate-600 with slate-100 hover; current page `bg-indigo-600 text-white font-semibold`.
- Ellipsis: a plain "…" span, `px-1 text-slate-400 select-none` — not a button.
- `navigation="input"` replaces the links with a `w-9` centred input (`rounded-md border border-slate-200 dark:border-slate-700 py-0.5 bg-transparent`) followed by "of **M**".

## Behaviour
- Renders nothing when everything fits on one page. Page changes are clamped to 1…totalPages in one place.
- Which pages to show: `edges` links pinned at each end, `siblings` either side of the current page, gaps between. If totalPages ≤ edges×2 + siblings×2 + 3, list every page. A gap that would hide exactly ONE page shows that page instead ("1 … 3" is no shorter than "1 2 3"). The window keeps its width near the ends. `showEllipsis={false}` lists every page and ignores `edges`.
- The page input is local while typing and commits on blur or Enter (a half-typed "12" never jumps to page 1); a non-number reverts; it resyncs when the page changes.
- `reportTemplate` rewrites the range line with `{first}`, `{last}`, `{total}`, `{page}`, `{totalPages}` (locale-formatted), e.g. "Showing {first} to {last} of {total}". rangeStart is 0 when there are no items.
- Changing the page size is the caller's job (usually also resetting to page 1).

## API
- `totalItems`, `itemsPerPage`, `currentPage`, `onPageChange(page)`, `onItemsPerPageChange(size)`; `itemType` ('rows'), `variant` ('bar' | 'pill' | 'floating'), `navigation` ('pages' | 'input'), `siblings` (1), `edges` (1), `showEllipsis` / `showRange` / `showPageSize` (true), `pageSizes` ([25, 50, 100, 250]), `reportTemplate`.
- Composable parts as static members, sharing state through context so they can be arranged freely: `Pagination.Root` (`total` items, `itemsPerPage`, `page`, `onPageChange`, `siblings`, `edges`, `showEllipsis`, `shape: 'rounded' | 'pill'`), `.Content` (a `nav` `flex flex-wrap items-center gap-2`), `.First` / `.Prev` / `.Next` / `.Last` (children replace the icon), `.Pages` (optional render prop per page), `.Page`, `.Ellipsis`, `.Report` (optional render prop over `{ page, totalPages, total, rangeStart, rangeEnd }`, or `itemType`). A part used outside Root throws an error naming the fix. Build the props-driven component FROM these parts so the two can never drift.

## Accessibility
- `nav aria-label="Pagination"` on Content; steppers labelled "First page", "Previous page", "Next page", "Last page"; each link `aria-label="Page N"` with `aria-current="page"` on the current one; the ellipsis is `aria-hidden`; the input is labelled "Page number".

## Demo
1,204 members, 25 per page, starting on page 3: a `bar` inside a bordered box, and a `pill` below it. Plus a composed layout: "← Newer" Prev, "Showing 31–40 of 480", the page links and "Older →" Next, spread with `justify-between`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
import { cn } from '@/lib/cn';
import { pageRange } from '@/lib/page-range';

const PAGE_SIZES = [25, 50, 100, 250];

export type PaginationVariant = 'bar' | 'pill' | 'floating';
export type PaginationNavigation = 'pages' | 'input';

/** The wrapper is the only thing the three placements differ in. */
const WRAPPER = {
  bar: 'shrink-0 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-t border-slate-200 dark:border-slate-800 px-4 py-2',
  pill: 'shrink-0 mt-3 mx-auto w-fit flex items-center gap-4 rounded-full border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-4 py-2 shadow-lg',
  floating:
    'fixed bottom-4 left-1/2 z-20 -translate-x-1/2 flex items-center gap-4 rounded-full border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-4 py-2 shadow-lg',
} as const;

/**
 * Page controls plus a page-size selector.
 *
 * Two axes, deliberately kept on ONE component rather than split into two.
 *
 * `navigation` is what sits in the middle:
 *   pages   numbered links with ellipsis gaps — the conventional paginator, and
 *           the only one that shows you where you are without reading a number.
 *   input   a "page N of M" box. Better past a few hundred pages, where numbered
 *           links stop being a map and start being noise.
 *
 * `variant` is placement:
 *   bar       a full-width strip with a top border — sits under a table
 *   pill      a centred rounded pill, in flow below the table
 *   floating  the same pill, fixed to the bottom of the viewport
 *
 * They were four separate components once, differing only in their wrapper.
 *
 * For a layout the props cannot express, the same control is also available as
 * COMPOSABLE PARTS on this component — `Pagination.Root`, `.Content`, `.First`,
 * `.Prev`, `.Pages`, `.Page`, `.Ellipsis`, `.Next`, `.Last`, `.Report`. This
 * prop-driven entry point is BUILT FROM those parts, so the two cannot drift:
 * there is one page button, one ellipsis, one clamp and one range line.
 */
export default function Pagination({
  totalItems,
  itemsPerPage,
  currentPage,
  onPageChange,
  onItemsPerPageChange,
  itemType = 'rows',
  variant = 'bar',
  navigation = 'pages',
  siblings = 1,
  edges = 1,
  showEllipsis = true,
  showRange = true,
  showPageSize = true,
  pageSizes = PAGE_SIZES,
  reportTemplate,
}: {
  totalItems: number;
  itemsPerPage: number;
  currentPage: number;
  onPageChange: (page: number) => void;
  onItemsPerPageChange: (size: number) => void;
  /** Named in the range line: "1–25 of 400 members". */
  itemType?: string;
  variant?: PaginationVariant;
  navigation?: PaginationNavigation;
  /** `pages` only — how many page links either side of the current one. */
  siblings?: number;
  /** `pages` only — how many links pinned at each end. */
  edges?: number;
  /** `pages` only. With gaps off, every page is listed and `edges` is moot. */
  showEllipsis?: boolean;
  showRange?: boolean;
  showPageSize?: boolean;
  /** The choices in the rows-per-page select. */
  pageSizes?: readonly number[];
  /**
   * The range line as a template — `{first}`, `{last}`, `{total}`, `{page}`,
   * `{totalPages}` — e.g. "Showing {first} to {last} of {total}" or
   * "Page {page} of {totalPages}". Absent, "1–25 of 400 rows".
   */
  reportTemplate?: string;
}) {
  const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
  if (totalPages <= 1 && totalItems <= itemsPerPage) return null;

  const isPill = variant !== 'bar';
  const report = reportTemplate ? (
    <Report>
      {(r) =>
        reportTemplate
          .replaceAll('{first}', r.rangeStart.toLocaleString())
          .replaceAll('{last}', r.rangeEnd.toLocaleString())
          .replaceAll('{total}', r.total.toLocaleString())
          .replaceAll('{page}', r.page.toLocaleString())
          .replaceAll('{totalPages}', r.totalPages.toLocaleString())
      }
    </Report>
  ) : (
    <Report itemType={itemType} />
  );

  return (
    <Root
      total={totalItems}
      itemsPerPage={itemsPerPage}
      page={currentPage}
      onPageChange={onPageChange}
      siblings={siblings}
      edges={edges}
      showEllipsis={showEllipsis}
      shape={isPill ? 'pill' : 'rounded'}
    >
      <div className={cn(WRAPPER[variant], 'text-xs')}>
        {showRange && (isPill ? <span className="hidden sm:block">{report}</span> : report)}

        <div className="flex items-center gap-3">
          {showPageSize && (
            <label className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300">
              Rows
              <select
                value={itemsPerPage}
                onChange={(e) => onItemsPerPageChange(Number(e.target.value))}
                className="rounded-md border border-slate-200 bg-transparent px-1.5 py-0.5 dark:border-slate-700"
              >
                {pageSizes.map((size) => (
                  <option key={size} value={size}>
                    {size}
                  </option>
                ))}
              </select>
            </label>
          )}

          <div className="flex items-center gap-0.5">
            <First />
            <Prev />
            {navigation === 'pages' ? <Pages /> : <PageInput />}
            <Next />
            <Last />
          </div>
        </div>
      </div>
    </Root>
  );
}

/* ---------------------------------------------------------------------------
   COMPOSABLE PARTS.

   The prop-driven component above covers the common layout in four props. This
   is the other half of that trade: every part is a separate element you arrange
   yourself, with anything you like between them — a label, an input, a spinner.

   They live in this file rather than a second component because they are the
   same control with a different entry point. Split apart they looked like two
   components that render identically, which is a worse thing to hand someone
   than one component with two ways in.
--------------------------------------------------------------------------- */

type PaginationShape = 'rounded' | 'pill';

type PaginationContextValue = {
  page: number;
  totalPages: number;
  total: number;
  itemsPerPage: number;
  rangeStart: number;
  rangeEnd: number;
  isFirst: boolean;
  isLast: boolean;
  go: (page: number) => void;
  tokens: ReturnType<typeof pageRange>;
  shape: PaginationShape;
};

const PaginationContext = createContext<PaginationContextValue | null>(null);

/**
 * Every part reads its state from here rather than taking props, which is what
 * lets them be rearranged freely. The error names the fix, because "cannot read
 * property of null" from inside a chevron button is not a useful place to land.
 */
function usePagination(part: string): PaginationContextValue {
  const ctx = useContext(PaginationContext);
  if (!ctx) throw new Error(`<Pagination.${part}> must be rendered inside <Pagination.Root>`);
  return ctx;
}

function Root({
  total,
  itemsPerPage,
  page,
  onPageChange,
  siblings = 1,
  edges = 1,
  showEllipsis = true,
  shape = 'rounded',
  children,
}: {
  /** Total number of ITEMS, not pages. */
  total: number;
  itemsPerPage: number;
  page: number;
  onPageChange: (page: number) => void;
  /** Page links either side of the current one. */
  siblings?: number;
  /** Page links pinned at each end. */
  edges?: number;
  showEllipsis?: boolean;
  /** Corner radius of every button — `pill` is what the pill placements use. */
  shape?: PaginationShape;
  children: React.ReactNode;
}) {
  const totalPages = Math.max(1, Math.ceil(total / itemsPerPage));
  // Clamped here, once, so no individual part has to guard its own arithmetic.
  const go = (next: number) => onPageChange(Math.min(Math.max(next, 1), totalPages));

  return (
    <PaginationContext.Provider
      value={{
        page,
        totalPages,
        total,
        itemsPerPage,
        rangeStart: total > 0 ? (page - 1) * itemsPerPage + 1 : 0,
        rangeEnd: Math.min(page * itemsPerPage, total),
        isFirst: page <= 1,
        isLast: page >= totalPages,
        go,
        tokens: pageRange({ page, totalPages, siblings, edges, showEllipsis }),
        shape,
      }}
    >
      {children}
    </PaginationContext.Provider>
  );
}

function Content({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <nav
      aria-label="Pagination"
      className={cn('flex flex-wrap items-center gap-2 text-xs text-slate-600 dark:text-slate-300', className)}
    >
      {children}
    </nav>
  );
}

/** The pill shape also presses in on click; the rounded one stays flat under a table. */
const NAV_SHAPE = {
  rounded: 'rounded-lg dark:hover:bg-slate-800',
  pill: 'rounded-full dark:hover:bg-slate-700 transition active:scale-90 disabled:active:scale-100',
} as const;

const navButton =
  'inline-flex items-center gap-1 p-1.5 text-slate-500 transition-colors hover:bg-slate-100 disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-400';

/** The four steppers differ only in target page, icon and label. */
function stepper(
  part: string,
  defaultIcon: React.ReactNode,
  target: (c: PaginationContextValue) => number,
  disabled: (c: PaginationContextValue) => boolean,
  label: string,
) {
  return function Stepper({ children, className }: { children?: React.ReactNode; className?: string }) {
    const ctx = usePagination(part);
    return (
      <button
        type="button"
        onClick={() => ctx.go(target(ctx))}
        disabled={disabled(ctx)}
        aria-label={label}
        className={cn(navButton, NAV_SHAPE[ctx.shape], className)}
      >
        {children ?? defaultIcon}
      </button>
    );
  };
}

const First = stepper('First', <ChevronsLeft className="h-3.5 w-3.5" />, () => 1, (c) => c.isFirst, 'First page');
const Prev = stepper('Prev', <ChevronLeft className="h-3.5 w-3.5" />, (c) => c.page - 1, (c) => c.isFirst, 'Previous page');
const Next = stepper('Next', <ChevronRight className="h-3.5 w-3.5" />, (c) => c.page + 1, (c) => c.isLast, 'Next page');
const Last = stepper('Last', <ChevronsRight className="h-3.5 w-3.5" />, (c) => c.totalPages, (c) => c.isLast, 'Last page');

function Page({
  page,
  children,
  className,
}: {
  page: number;
  children?: React.ReactNode;
  className?: string;
}) {
  const ctx = usePagination('Page');
  const active = page === ctx.page;
  return (
    <button
      type="button"
      onClick={() => ctx.go(page)}
      aria-label={`Page ${page}`}
      aria-current={active ? 'page' : undefined}
      className={cn(
        'min-w-[1.75rem] px-1.5 py-1 text-center transition-colors',
        ctx.shape === 'pill' ? 'rounded-full' : 'rounded-lg',
        active
          ? 'bg-indigo-600 font-semibold text-white'
          : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800',
        className,
      )}
    >
      {children ?? page}
    </button>
  );
}

function Ellipsis({ children, className }: { children?: React.ReactNode; className?: string }) {
  // Not a button. A focusable gap is a keyboard stop that does nothing, and
  // `aria-hidden` keeps it out of the reading order as well.
  return (
    <span aria-hidden className={cn('select-none px-1 text-slate-400', className)}>
      {children ?? '…'}
    </span>
  );
}

function Pages({
  children,
  className,
}: {
  /** Render prop for one page link. Omit for the default `<Pagination.Page>`. */
  children?: (page: number) => React.ReactNode;
  className?: string;
}) {
  const ctx = usePagination('Pages');
  return (
    <span className={cn('flex items-center gap-0.5', className)}>
      {ctx.tokens.map((token, i) =>
        token === 'ellipsis' ? (
          // eslint-disable-next-line react/no-array-index-key
          <Ellipsis key={`gap-${i}`} />
        ) : children ? (
          <span key={token}>{children(token)}</span>
        ) : (
          <Page key={token} page={token} />
        ),
      )}
    </span>
  );
}

/**
 * The "page N of M" box — the `navigation="input"` middle. Typing is local
 * until blur or Enter, so a half-typed "12" does not jump to page 1 first.
 */
function PageInput() {
  const { page, totalPages, go } = usePagination('PageInput');
  const [pageInput, setPageInput] = useState(String(page));

  useEffect(() => setPageInput(String(page)), [page]);

  const commit = () => {
    const n = Number.parseInt(pageInput, 10);
    if (Number.isNaN(n)) setPageInput(String(page));
    else go(n);
  };

  return (
    <span className="flex items-center gap-1 px-1 text-slate-600 dark:text-slate-300">
      <input
        value={pageInput}
        onChange={(e) => setPageInput(e.target.value)}
        onBlur={commit}
        onKeyDown={(e) => e.key === 'Enter' && commit()}
        aria-label="Page number"
        className="w-9 rounded-md border border-slate-200 bg-transparent py-0.5 text-center dark:border-slate-700"
      />
      of <span className="font-semibold">{totalPages}</span>
    </span>
  );
}

function Report({
  children,
  itemType,
  className,
}: {
  /** Render prop over the current numbers. Omit for "1–25 of 480". */
  children?: (state: {
    page: number;
    totalPages: number;
    total: number;
    rangeStart: number;
    rangeEnd: number;
  }) => React.ReactNode;
  /** Appended to the default line: "1–25 of 480 members". */
  itemType?: string;
  className?: string;
}) {
  const { page, totalPages, total, rangeStart, rangeEnd } = usePagination('Report');
  return (
    <span className={cn('text-slate-600 dark:text-slate-300', className)}>
      {children ? (
        children({ page, totalPages, total, rangeStart, rangeEnd })
      ) : (
        <>
          <span className="font-semibold">{rangeStart}</span>–
          <span className="font-semibold">{rangeEnd}</span> of{' '}
          <span className="font-semibold">{total}</span>
          {itemType && ` ${itemType}`}
        </>
      )}
    </span>
  );
}

Pagination.Root = Root;
Pagination.Content = Content;
Pagination.First = First;
Pagination.Prev = Prev;
Pagination.Pages = Pages;
Pagination.Page = Page;
Pagination.Ellipsis = Ellipsis;
Pagination.Next = Next;
Pagination.Last = Last;
Pagination.Report = Report;

export { Root, Content, First, Prev, Pages, Page, Ellipsis, Next, Last, Report };

Props

PropTypeDefaultDescription
totalItems*number—
itemsPerPage*number—
currentPage*number—
onPageChange*(page: number) => void—
onItemsPerPageChange*(size: number) => void—
itemTypestring'rows'Named in the range line: "1–25 of 400 members".
variantPaginationVariant'bar'
navigationPaginationNavigation'pages'
siblingsnumber1`pages` only — how many page links either side of the current one.
edgesnumber1`pages` only — how many links pinned at each end.
showEllipsisbooleantrue`pages` only. With gaps off, every page is listed and `edges` is moot.
showRangebooleantrue
showPageSizebooleantrue
pageSizesreadonly number[]PAGE_SIZESThe choices in the rows-per-page select.
reportTemplatestring—The range line as a template — `{first}`, `{last}`, `{total}`, `{page}`, `{totalPages}` — e.g. "Showing {first} to {last} of {total}" or "Page {page} of {totalPages}". Absent, "1–25 of 400 rows".