Previous, numbered pages and Next beside your range, as server-rendered links or as buttons.

The row shows your formatted range, then Previous, the first page, the current page with one page each side, the last page, gaps for the rest, and Next.

Pass getPageHref and every control is a link it builds. Pagination has no client directive, so a server page can render it and pass the function. Only the links and the status region are client components. In an app, the server page reads searchParams.page and formats the range. The docs are static, so this example reads the page from the URL in the browser.

Example

Follows the page theme and corners
'use client';

import { Pagination, RouterProvider } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, type ReactElement } from 'react';

const ROWS = 1284;
const PAGE_SIZE = 50;
const PAGE_COUNT = Math.ceil(ROWS / PAGE_SIZE);

/** A count with a comma between thousands, written by hand: no Intl, so the server and the browser agree. */
const withCommas = (count: number): string => String(count).replace(/\B(?=(\d{3})+(?!\d))/g, ',');

function Pages({ page }: { page: number }): ReactElement {
  const pathname = usePathname();
  const first = (page - 1) * PAGE_SIZE + 1;
  const last = Math.min(page * PAGE_SIZE, ROWS);
  return (
    <Pagination
      page={page}
      pageCount={PAGE_COUNT}
      rangeLabel={`${withCommas(first)} to ${withCommas(last)} of ${withCommas(ROWS)}`}
      getPageHref={(n) => `${pathname}?page=${String(n)}`}
      labels={{ navigation: 'Transactions pages' }}
    />
  );
}

function PageFromUrl(): ReactElement {
  const page = Number(useSearchParams().get('page') ?? '1');
  return <Pages page={Number.isInteger(page) && page >= 1 && page <= PAGE_COUNT ? page : 1} />;
}

export default function PaginationLinks(): ReactElement {
  const router = useRouter();
  // An app wraps its whole tree once; the example wraps only itself.
  return (
    <div style={{ width: '100%' }}>
      <RouterProvider
        navigate={(href) => {
          router.push(href, { scroll: false });
        }}
      >
        {/* The page is static, so the server renders the fallback: the same row at the first page. */}
        <Suspense fallback={<Pages page={1} />}>
          <PageFromUrl />
        </Suspense>
      </RouterProvider>
    </div>
  );
}

Buttons

Pass onPageChange instead of getPageHref and every control is a button that calls it with its page. Pressing the current page calls nothing. Use button mode from a client component, since a function cannot pass from a server page to the client.

Example

Follows the page theme and corners
'use client';

import { Pagination } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const ROWS = 230;
const PAGE_SIZE = 50;

export default function PaginationButtons(): ReactElement {
  const [page, setPage] = useState(1);
  const first = (page - 1) * PAGE_SIZE + 1;
  const last = Math.min(page * PAGE_SIZE, ROWS);
  return (
    <div style={{ width: '100%' }}>
      <Pagination
        page={page}
        pageCount={Math.ceil(ROWS / PAGE_SIZE)}
        rangeLabel={`${String(first)} to ${String(last)} of ${String(ROWS)}`}
        onPageChange={setPage}
        labels={{ navigation: 'Rules pages' }}
      />
    </div>
  );
}

Narrow

The row responds to its own width, not the viewport's, so a narrow column on a wide page narrows too.

  • Under 36rem: the step words hide but remain the steps' names.
  • Under 30rem: the numbers and gaps hide too, leaving Previous, the range and Next, with two 40px targets.

hasPageNumbers={false} gives the short row at every width.

Example

Follows the page theme and corners
import { Pagination } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function PaginationNarrow(): ReactElement {
  // A 20rem column: the row lays itself out by its own width, whatever the page's.
  return (
    <div style={{ width: '100%', maxWidth: '20rem' }}>
      <Pagination
        page={12}
        pageCount={26}
        rangeLabel="551 to 600 of 1,284"
        labels={{ navigation: 'Statement pages' }}
        getPageHref={(n) => `#page-${String(n)}`}
      />
    </div>
  );
}

In a row

Pagination works as a plain item in any parent, with no wrapper:

  • In a block, or a grid column sized with fr, it fills the width.
  • In a flex row, wrapping or not, it takes the room its siblings leave, so a link such as "Show 100 a page" keeps its own width at the end.
  • In a column sized by its content, such as a grid column of auto or a parent of width: fit-content, it is as wide as the narrow row: the range between the two steps. Give that column minmax(0, 1fr) for the full row.

On a phone it narrows with the row it is in, never below the range's longest word between the steps, and the range wraps between words.

Example

Show 100 a page
Follows the page theme and corners
'use client';

import { Link, Pagination } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const ROWS = 1284;
const PAGE_SIZE = 50;

export default function PaginationInARow(): ReactElement {
  const [page, setPage] = useState(1);
  const first = (page - 1) * PAGE_SIZE + 1;
  const last = Math.min(page * PAGE_SIZE, ROWS);
  return (
    // Your row: Pagination takes the room the link leaves, and the link keeps the end.
    <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-inset)', width: '100%' }}>
      <Pagination
        page={page}
        pageCount={Math.ceil(ROWS / PAGE_SIZE)}
        rangeLabel={`${String(first)} to ${String(last)} of 1,284`}
        onPageChange={setPage}
        labels={{ navigation: 'Pages beside a link' }}
      />
      <Link href="#show-100">Show 100 a page</Link>
    </div>
  );
}

More siblings

siblingCount sets how many pages show each side of the current one, 1 by default. Since the step words hide under 36rem, the numbers still fit just above 30rem. Here two siblings each side fit in a 30.5rem column. If the row is still wider than the nav, as with many siblings on a phone's larger controls, the list wraps to the end of the row instead of scrolling the page sideways.

Example

Follows the page theme and corners
import { Pagination } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function PaginationSiblings(): ReactElement {
  // A 30.5rem column, just above the narrow row: the numbers stay, two each side, and the step words hide.
  return (
    <div style={{ width: '100%', maxWidth: '30.5rem' }}>
      <Pagination
        page={13}
        pageCount={26}
        siblingCount={2}
        rangeLabel="601 to 650 of 1,284"
        labels={{ navigation: 'Ledger pages' }}
        getPageHref={(n) => `#ledger-page-${String(n)}`}
      />
    </div>
  );
}

Page size

Pass pageSize and onPageSizeChange together and the row starts with a rows-per-page picker, labelled "Rows per page" (labels.pageSize), with the page controls at its end. It offers 10, 25, 50 and 100 rows, or your pageSizeOptions, written with pageLabel like the page numbers. onPageSizeChange gets the size chosen and the page that holds the first row of the current page at that size, so the reader keeps their place: page 3 at 25 a page starts at row 51, which is on page 6 at 10 a page. Set both, and write the new range, which is announced once it settles.

The picker is the browser's own select, drawn as the library's fields are, with a visible label tied to it, so a phone opens its own wheel or sheet and a Pagination carries no list or popover of its own. Where the row is narrow it takes a line of its own above the steps, and a right-to-left page puts it at the right. It calls your function, so render Pagination from a client component to show it, in link mode or button mode; from a server page, render it without a picker, or move it into a client component, as this example and the Transactions recipe do. The size belongs in the URL beside the page:

'use client';

const SIZES = [10, 25, 50, 100];
const DEFAULT_SIZE = 25;

function Pages({ page, size, total }: { page: number; size: number; total: number }) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  // The URL with some parameters changed; the defaults write none, and a bare ? is never written.
  const hrefWith = (changes: Record<string, string | null>) => {
    const params = new URLSearchParams(searchParams);
    for (const [name, value] of Object.entries(changes)) {
      if (value === null) params.delete(name);
      else params.set(name, value);
    }
    const query = params.toString();
    return query ? `${pathname}?${query}` : pathname;
  };
  return (
    <Pagination
      page={page}
      pageCount={Math.ceil(total / size)}
      rangeLabel={formatRange(page, size, total)}
      getPageHref={(n) => hrefWith({ page: n === 1 ? null : String(n) })}
      pageSize={size}
      pageSizeOptions={SIZES}
      onPageSizeChange={(nextSize, nextPage) => {
        router.replace(
          hrefWith({
            size: nextSize === DEFAULT_SIZE ? null : String(nextSize),
            page: nextPage === 1 ? null : String(nextPage),
          }),
          { scroll: false },
        );
      }}
    />
  );
}

A new size is replaced in history, as a sort is, with { scroll: false }, so the page does not jump. Read the size back from the URL and check it against your sizes, as the page is checked.

Example

Follows the page theme and corners
'use client';

import { Pagination, RouterProvider } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, type ReactElement } from 'react';

const ROWS = 240;
const SIZES = [10, 25, 50, 100];
const DEFAULT_SIZE = 25;

/** The page and the size from the URL, each checked: anything else is the first page at the default size. */
function stateOf(params: URLSearchParams): { page: number; size: number } {
  const asked = Number(params.get('size') ?? DEFAULT_SIZE);
  const size = SIZES.includes(asked) ? asked : DEFAULT_SIZE;
  const pageCount = Math.ceil(ROWS / size);
  const page = Number(params.get('page') ?? '1');
  return { page: Number.isInteger(page) && page >= 1 && page <= pageCount ? page : 1, size };
}

function Pages({ params }: { params: URLSearchParams }): ReactElement {
  const router = useRouter();
  const pathname = usePathname();
  const { page, size } = stateOf(params);
  /** The URL with some parameters changed; the defaults write none, and a bare `?` is never written. */
  const hrefWith = (changes: Record<string, number | null>): string => {
    const next = new URLSearchParams(params);
    for (const [name, value] of Object.entries(changes)) {
      if (value === null) next.delete(name);
      else next.set(name, String(value));
    }
    const query = next.toString();
    return query ? `${pathname}?${query}` : pathname;
  };
  const first = (page - 1) * size + 1;
  const last = Math.min(page * size, ROWS);
  return (
    <Pagination
      page={page}
      pageCount={Math.ceil(ROWS / size)}
      rangeLabel={`${String(first)} to ${String(last)} of ${String(ROWS)}`}
      getPageHref={(n) => hrefWith({ page: n === 1 ? null : n })}
      pageSize={size}
      pageSizeOptions={SIZES}
      // The page Pagination works out keeps the first row of this page in view at the new size.
      onPageSizeChange={(nextSize, nextPage) => {
        router.replace(
          hrefWith({
            size: nextSize === DEFAULT_SIZE ? null : nextSize,
            page: nextPage === 1 ? null : nextPage,
          }),
          { scroll: false },
        );
      }}
      labels={{ navigation: 'Payment pages' }}
    />
  );
}

function PagesFromUrl(): ReactElement {
  return <Pages params={new URLSearchParams(useSearchParams())} />;
}

export default function PaginationPageSize(): ReactElement {
  const router = useRouter();
  // An app wraps its whole tree once; the example wraps only itself.
  return (
    <div style={{ width: '100%' }}>
      <RouterProvider
        navigate={(href) => {
          router.push(href, { scroll: false });
        }}
      >
        {/* The page is static, so the server renders the fallback: the first page at the default size. */}
        <Suspense fallback={<Pages params={new URLSearchParams()} />}>
          <PagesFromUrl />
        </Suspense>
      </RouterProvider>
    </div>
  );
}

Without numbers

For a cursor-paged list, where the API says only whether another page follows, pass hasPageNumbers={false} and pageCount={hasMore ? page + 1 : page}. While there is more, the count is one past this page, which enables Next. On the last page it equals this page, which disables Next. Write the range from what you know, here "1 to 25" with no total.

Example

Follows the page theme and corners
'use client';

import { Pagination } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const PAGE_SIZE = 25;

/** What a cursor-paged API answers: this page's rows and whether another page follows, never a total. */
const cursorPage = (page: number): { rows: number; hasMore: boolean } => ({
  rows: page < 4 ? PAGE_SIZE : 12,
  hasMore: page < 4,
});

export default function PaginationCursor(): ReactElement {
  const [page, setPage] = useState(1);
  const { rows, hasMore } = cursorPage(page);
  const first = (page - 1) * PAGE_SIZE + 1;
  const last = first + rows - 1;
  return (
    <div style={{ width: '100%' }}>
      <Pagination
        page={page}
        // The count is unknown, so it is one past this page while there is more, which enables Next.
        pageCount={hasMore ? page + 1 : page}
        hasPageNumbers={false}
        rangeLabel={`${String(first)} to ${String(last)}`}
        onPageChange={setPage}
        labels={{ navigation: 'Activity pages' }}
      />
    </div>
  );
}

Other digits

pageLabel writes a page number your way. Here it gives Persian digits with a string replacement and no Intl, so the server and the browser agree. Each page's name is labels.page called with that text, and the other labels translate the rest. In right to left, the list runs from the right and the chevrons point the way they go. A number's digits keep their order.

Example

Follows the page theme and corners
import { Pagination } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

/** Persian digits, written without Intl, so the server and the browser agree. */
const persian = (value: number | string): string =>
  String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)] ?? d);

export default function PaginationPersian(): ReactElement {
  return (
    <div dir="rtl" lang="fa" style={{ width: '100%' }}>
      <Pagination
        page={3}
        pageCount={12}
        rangeLabel={`${persian(101)} تا ${persian(150)} از ${persian('1٬284')}`}
        pageLabel={persian}
        getPageHref={(n) => `#page-${String(n)}`}
        labels={{
          navigation: 'صفحه‌های تراکنش‌ها',
          previous: 'قبلی',
          next: 'بعدی',
          page: (label) => `صفحه ${label}`,
        }}
      />
    </div>
  );
}

Usage

Keep the page in the URL so it can be shared, reloaded and returned to. With the Next App Router, the server page reads the search parameter, formats the range and renders Pagination, with no client component of its own:

// app/transactions/page.tsx, a server component
import { Pagination } from '@snapshot-ui/react';

const PAGE_SIZE = 50;

export default async function TransactionsPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
  const { page: param = '1' } = await searchParams;
  const total = await countTransactions();
  const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
  const page = Math.min(Math.max(1, Number.parseInt(param, 10) || 1), pageCount);
  const first = (page - 1) * PAGE_SIZE + 1;
  const last = Math.min(page * PAGE_SIZE, total);
  const range =
    total === 0 ? 'No transactions' : `${formatCount(first)} to ${formatCount(last)} of ${formatCount(total)}`;
  return (
    <>
      <TransactionList page={page} />
      <Pagination page={page} pageCount={pageCount} rangeLabel={range} getPageHref={(n) => '?page=' + String(n)} />
    </>
  );
}

formatCount is yours. Pagination never formats a number, so you also word the range when there are no rows.

The links go through the RouterProvider at the app's root, as Routing sets up. A page change is then a soft navigation, and the status region stays mounted to announce the new range. If it is silent after a navigation, render Pagination in a layout segment that persists. When the URL has other parameters, such as a search or a view, build each href from them so changing the page keeps them.

  • The row always has 2 times siblingCount plus 5 slots, seven by default, so the controls never move under the pointer. Choose a page size that keeps the page count steady while a person pages.
  • For an endless or cursor-paged list, use hasPageNumbers={false}, as Without numbers shows.
  • pageCount and siblingCount count whole pages. A pageCount that is not a finite number means no pages.
  • A page that is not a whole number from 1 to pageCount shows as the nearest page, with a console warning.

Props

Every nav attribute is accepted except aria-label, which comes from labels.navigation. Pass getPageHref or onPageChange, never both.

className
string

getPageHref
((page: number) => string)

hasPageNumbers
boolean

Shows numbered pages and gaps between Previous and Next when the row is at least 30rem wide. Off, the row is Previous, the range and Next at every width.

Default: true

labels
Partial<PaginationLabels>

The library's own words: the landmark's name, Previous, Next, each page control's name and the picker's label.

onPageChange
((page: number) => void)

page
number

The current page, counted from 1.

pageCount
number

How many pages there are, in whole pages. A count that is not a finite number means none.

pageLabel
((page: number) => string)

How a page number, and a page size in the picker, is written, such as Persian digits. Runs in the shell.

Default: String(page)

rangeLabel
string

The range, formatted by the app: "1 to 50 of 1,284". Shown, and spoken after a change.

siblingCount
number

Page numbers shown each side of the current one, in whole pages. A non-finite value means 1.

Default: 1

onPageSizeChange
(pageSize: number, page: number) => void

Called with the size chosen and the page that holds the first row of the current page at that size, so the reader keeps their place. Set both, and write a new rangeLabel, which is spoken once it settles.

pageSize
number

The rows on a page, the picker's value.

pageSizeOptions
readonly number[]

The sizes the picker offers, in whole rows, smallest first; pageSize is always among them.

Default: [10, 25, 50, 100]

navigation
string

The navigation landmark's name, which tells this row from the page's other navigation.

Default: 'Pagination'

next
string

The step to the page after. Shown beside its chevron when the row has room, and its hidden name otherwise.

Default: 'Next'

page
(label: string) => string

A page control's name, given the page's written label.

Default: (label) => `Page ${label}`

pageSize
string

The page size picker's visible label, which names it.

Default: 'Rows per page'

previous
string

The step to the page before. Shown beside its chevron when the row has room, and its hidden name otherwise.

Default: 'Previous'

Accessibility

A nav named "Pagination" (or labels.navigation) holds a list of links or buttons. The current page has aria-current: "page" on a link, "true" on a button. With two on one page, give each its own name, as the examples here do.

  • Names: each page is named "Page" and its number, and the steps by their words, even when the narrow row hides them.
  • Announcements: the range is announced once, 600ms after it last changed, so three quick presses of Next announce one range. Nothing is announced on page load, and the visible range is not live, so it is never read twice.
  • The page size picker: a native select named by its visible label, "Rows per page", inside the navigation landmark. A new size changes the range, which is announced as a page change is.
  • Disabled steps: they leave the tab order. If a focused step disables, as Next does on the last page, focus moves to the current page, or to the other step when the numbers are hidden.
TabMoves between the controls.
EnterGoes to the page; a button also takes Space.
UpDownOn the page size picker, the browser's own keys: on Windows and Linux they choose the size before or after at once, and Alt+Down opens the list; on a Mac they open the list, where Enter chooses.

Design rules

What keeps pagination in the system.

Write the range in your own words: "1 to 50 of 1,284".

Render link mode from a server page, with the page in the URL.

Choose a page size that keeps the controls still while a person pages.

Keep the page size in the URL beside the page, and offer a few sizes.

Never ask the library to format a page number. Pass pageLabel for other digits.

Never show a pager without the range.

Never page a list a person reads to the end. Show it whole.

esc

↑ ↓ to move↵ to open