Skip to content
design
Components

Pagination

Bounded page controls move through a collection while keeping its current page explicit.

On this page
  • Engineering note 1

    Illustrative report · documentation example

  • Engineering note 2

    Illustrative report · documentation example

  • Engineering note 3

    Illustrative report · documentation example

Showing report page 1 of 12.

Page through an illustrative collection, then compare the compact form.
Code
import { Pagination, type PaginationLabels } from "@bosunkit/components/pagination";
import { Switch } from "@bosunkit/components/switch";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
  "A local report collection changes with the page. Compact mode retains previous, next and a readable summary. All entries are illustrative.";
const labels: PaginationLabels = {
  previous: "Previous report page",
  next: "Next report page",
  first: "First report page",
  last: "Last report page",
  page: (page: number): string => `Report page ${String(page)}`,
  summary: (page: number, total: number): string => `Page ${String(page)} of ${String(total)}`,
};
export default function Showcase(): ReactElement {
  const [page, setPage]: [number, Dispatch<SetStateAction<number>>] = useState(1);
  const [compact, setCompact]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
  const results: string = useId();
  return (
    <div style={{ display: "grid", gap: "var(--space-lg)", width: "100%" }}>
      <label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
        <Switch checked={compact} onCheckedChange={setCompact} />
        Compact pagination
      </label>
      <ul id={results} style={{ display: "grid", gap: "var(--space-md)", margin: 0, padding: 0, listStyle: "none" }}>
        {[0, 1, 2].map((offset: number): ReactElement => (
          <li key={offset} style={{ padding: "var(--space-md)", borderBottom: "1px solid var(--border)" }}>
            <strong>Engineering note {String((page - 1) * 3 + offset + 1)}</strong>
            <p style={{ margin: 0, color: "var(--text-dim)" }}>Illustrative report · documentation example</p>
          </li>
        ))}
      </ul>
      <p role="status">Showing report page {String(page)} of 12.</p>
      <Pagination
        label="Report collection pages"
        pageCount={12}
        value={page}
        onValueChange={setPage}
        labels={labels}
        controls={results}
        compact={compact}
      />
    </div>
  );
}

Pagination controls a collection with a known number of pages. It renders numbered pages, noninteractive gaps, previous and next controls, and optional first and last shortcuts. Loading the results belongs to the caller.

01Keep the collection stableKeep filtering, sorting and the page size consistent while changing pages. Reset the page deliberately when those change.
02Show a bounded windowKeep the first, last and nearby pages available without rendering every page in a large collection.
03Preserve contextName the controlled results, keep page selection explicit, and announce when the requested results arrive.

Examples

Boundaries, loading and empty collections

The first page disables Previous; the final page disables Next. Zero pages exposes the caller's empty summary and disables every direction. A one-page collection has one current-page control and unavailable directions.

Empty collection

Single page

First/last shortcuts, disabled loading controls, an empty collection and a one-page collection. Empty is distinct from a loading total that is not known.
Code
import { Pagination, type PaginationLabels } from "@bosunkit/components/pagination";
import { Switch } from "@bosunkit/components/switch";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
  "First/last shortcuts, disabled loading controls, an empty collection and a one-page collection. Empty is distinct from a loading total that is not known.";
const labels: PaginationLabels = {
  previous: "Previous page",
  next: "Next page",
  first: "First page",
  last: "Last page",
  page: (page: number): string => `Page ${String(page)}`,
  summary: (page: number, total: number): string =>
    total === 0 ? "No pages" : `Page ${String(page)} of ${String(total)}`,
};
export default function States(): ReactElement {
  const [disabled, setDisabled]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
  return (
    <div style={{ display: "grid", gap: "var(--space-xl)" }}>
      <label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
        <Switch checked={disabled} onCheckedChange={setDisabled} />
        Disable controls while loading
      </label>
      <Pagination
        label="Large collection"
        pageCount={48}
        defaultValue={24}
        labels={labels}
        showFirstLast
        disabled={disabled}
      />
      <div>
        <p>Empty collection</p>
        <Pagination label="Empty collection" pageCount={0} labels={labels} />
      </div>
      <div>
        <p>Single page</p>
        <Pagination label="Single-page collection" pageCount={1} labels={labels} />
      </div>
    </div>
  );
}

The address selects collection page 1 of 5.

These are real links. Change page, then use browser Back and Forward: the address fragment owns this example's current page.
Code
import { Pagination, type PaginationLabels } from "@bosunkit/components/pagination";
import { useId, useSyncExternalStore, type ReactElement } from "react";

export const description: string =
  "These are real links. Change page, then use browser Back and Forward: the address fragment owns this example's current page.";
const labels: PaginationLabels = {
  previous: "Previous linked page",
  next: "Next linked page",
  first: "First linked page",
  last: "Last linked page",
  page: (page: number): string => `Linked page ${String(page)}`,
  summary: (page: number, total: number): string => `Page ${String(page)} of ${String(total)}`,
};
function subscribe(change: () => void): () => void {
  window.addEventListener("hashchange", change);
  return (): void => {
    window.removeEventListener("hashchange", change);
  };
}
function snapshot(): number {
  const match: RegExpExecArray | null = /^#collection-page=([1-5])$/.exec(window.location.hash);
  return match === null ? 1 : Number(match[1]);
}
function serverSnapshot(): number {
  return 1;
}
export default function Links(): ReactElement {
  const page: number = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
  const results: string = useId();
  return (
    <div style={{ display: "grid", gap: "var(--space-lg)", width: "100%" }}>
      <p id={results} role="status">
        The address selects collection page {String(page)} of 5.
      </p>
      <Pagination
        label="Linked collection pages"
        pageCount={5}
        value={page}
        labels={labels}
        controls={results}
        getPageHref={(value: number): string => `#collection-page=${String(value)}`}
      />
    </div>
  );
}

Local state or route state

Pass value and onValueChange when the application owns the page. Omit value to start from defaultValue and keep local state. Pages are one-based. The displayed current page is bounded to the available range; this does not write corrected state back to the caller or issue a request during rendering.

For URL pagination, supply getPageHref. Each available destination becomes a real link through the provider's adapter. The callback runs for an ordinary primary click; modified clicks retain browser behavior and do not change local selection. The router owns location, browser Back/Forward and fetching. Read page from that URL.

<Pagination
  label={t('reports.pages')}
  value={pageFromUrl}
  pageCount={pageCount}
  labels={translatedPaginationLabels}
  getPageHref={(page) => makeReportUrl({ page, filter, sort })}
  controls="report-results"
/>

Button mode is appropriate for a local collection or a controlled view with no separate page URL. Neither mode moves focus into results automatically. Keep focus on the activated control during loading, then announce a short result summary. Move focus only when the application intentionally treats the change as a new page.

Density and responsive behavior

The default window includes one nearby page on each side. siblingCount accepts zero through two; the rendered window stays bounded even for very large totals. Gaps are text, not hidden navigation actions.

compact shows previous, summary and next, retaining first/last shortcuts if requested. A bk-screen container at the phone breakpoint selects this form automatically. Controls wrap when no screen container is supplied. The summary formatter must explain both current and total, including the empty case.

Data contract

Use a finite, safe integer pageCount. Zero and invalid counts render the empty state. An unknown total is a different state: show loading feedback or caller-owned previous/next controls until the service provides a total. Do not present an unknown total as zero results.

When filters, sort order or page size change, update the page and query together. Cancel superseded requests, retain the chosen page until the response is accepted, and use disabled for a pending operation when further requests are unavailable. Keep errors near the results and provide retry without silently changing pages.

Props

PropTypeDefaultDescription
labelrequiredstring–Name of this navigation landmark.
pageCountrequirednumber–Total known pages. Zero or an invalid count produces the empty, disabled state.
valuenumber | undefined–Current one-based page; omit for uncontrolled use. Out-of-range values are bounded for display only.
defaultValuenumber | undefined1Initial page when uncontrolled; one by default.
onValueChange((page: number) => void) | undefined–Requests a page; the caller owns loading, URL state and result focus.
labelsrequiredPaginationLabels–Translated control names and summary formatters.
formatPage((page: number) => string) | undefinedStringOptional display formatter for page numerals; defaults to ungrouped integer digits.
getPageHref((page: number) => string) | undefined–Real URLs for route pagination; modified clicks retain normal browser behavior.
controlsstring | undefined–Id of the results region controlled by these controls.
disabledboolean | undefinedfalseDisables navigation, for example while the caller loads a requested page.
compactboolean | undefinedfalseShow only previous, the page summary and next; narrow screen containers also use this form.
showFirstLastboolean | undefinedfalseInclude first and last controls; false by default.
siblingCountnumber | undefined1Nearby pages on either side of the current page, bounded to zero through two; one by default.
classNamestring | undefined–Additional root classes.
testIdstring | undefined–Root test id.
refRef<HTMLElement> | undefined–The navigation landmark.
Do

Engineering reports · Page 3 of 12

Keep the result context with the page controls.
Don't

Next page opens a different collection.

Do not reset filters or move focus unexpectedly when the page changes.

Usage reference: Atlassian pagination.