Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

LayoutReact

Pagination

The controls of a paged list: a navigation landmark named "Pagination" with First, Previous, the page range, Next and Last.

Rendered by React 19.3.1

Loading React…
import { Pagination } from '@hamur/react/pagination'

const href = (page: number) => `?page=${page}`

export default function PaginationExample() {
  return (
    <Pagination.Root
      className="pagination"
      totalPages={10}
      defaultValue={4}
      getPageHref={href}
      // A router takes the link here; the example stays on its page.
        onValueChange={(_page: number, details: Pagination.Root.ChangeEventDetails) => details.event.preventDefault()}
    >
      <ul className="pagination-list">
        <li>
          <Pagination.Previous className="pagination-control">
            Previous
          </Pagination.Previous>
        </li>
        <Pagination.Collection>
          {(entry) => (
            <li>
              {entry.type === 'page' ? (
                <Pagination.Item value={entry.value} className="pagination-item">
                  {entry.value}
                </Pagination.Item>
              ) : (
                <Pagination.Ellipsis className="pagination-ellipsis" />
              )}
            </li>
          )}
        </Pagination.Collection>
        <li>
          <Pagination.Next className="pagination-control">
            Next
          </Pagination.Next>
        </li>
      </ul>
    </Pagination.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Pagination.Root>
  <Pagination.Previous />
  <Pagination.Collection>
    <Pagination.Item />
    <Pagination.Ellipsis />
  </Pagination.Collection>
  <Pagination.Next />
</Pagination.Root>

// Not in this example: Pagination.First, Pagination.Last, Pagination.PageSize, Pagination.Status, Pagination.Value

Examples

Buttons

Button mode with First and Last on 20 pages: each press reports its reason, and at a bound the control stays focusable with aria-disabled.

PaginationButtons.tsx
import * as React from 'react'
import { Pagination } from '@hamur/react/pagination'

export default function PaginationButtonsExample() {
  const [page, setPage] = React.useState(10)
  const [reason, setReason] = React.useState('none')
  return (
    <div className="stack">
      <Pagination.Root
        className="pagination"
        totalPages={20}
        value={page}
        onValueChange={(next, details) => {
          setPage(next)
          setReason(details.reason)
        }}
      >
        <ul className="pagination-list">
          <li>
            <Pagination.First className="pagination-control">«</Pagination.First>
          </li>
          <li>
            <Pagination.Previous className="pagination-control">‹</Pagination.Previous>
          </li>
          <Pagination.Collection>
            {(entry) => (
              <li>
                {entry.type === 'page' ? (
                  <Pagination.Item value={entry.value} className="pagination-item">
                    {entry.value}
                  </Pagination.Item>
                ) : (
                  <Pagination.Ellipsis className="pagination-ellipsis" />
                )}
              </li>
            )}
          </Pagination.Collection>
          <li>
            <Pagination.Next className="pagination-control">›</Pagination.Next>
          </li>
          <li>
            <Pagination.Last className="pagination-control">»</Pagination.Last>
          </li>
        </ul>
      </Pagination.Root>
      <p className="muted">
        Page: {page}. Last reason: {reason}
      </p>
    </div>
  )
}

Compact

The data table shape: Value reads "Page 1 of 5" between four buttons, under a table of 23 invoices, 5 per page, from itemCount and pageSize.

PaginationCompact.tsx
import * as React from 'react'
import { Pagination } from '@hamur/react/pagination'

const PAGE_SIZE = 5
const invoices = Array.from({ length: 23 }, (_, i) => ({
  id: `INV-${1001 + i}`,
  amount: `$${(120 + i * 15).toFixed(2)}`,
}))

export default function PaginationCompactExample() {
  const [page, setPage] = React.useState(1)
  const rows = invoices.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
  return (
    <div className="stack">
      <table className="pagination-table">
        <thead>
          <tr>
            <th scope="col">Invoice</th>
            <th scope="col">Amount</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((row) => (
            <tr key={row.id}>
              <td>{row.id}</td>
              <td>{row.amount}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <Pagination.Root
        className="pagination pagination-compact"
        aria-label="Invoice pages"
        itemCount={invoices.length}
        pageSize={PAGE_SIZE}
        value={page}
        onValueChange={setPage}
      >
        <Pagination.First className="pagination-control">«</Pagination.First>
        <Pagination.Previous className="pagination-control">‹</Pagination.Previous>
        <Pagination.Value className="pagination-value" />
        <Pagination.Next className="pagination-control">›</Pagination.Next>
        <Pagination.Last className="pagination-control">»</Pagination.Last>
      </Pagination.Root>
    </div>
  )
}

Page Size

itemCount of 95 with PageSize of 10, 20 or 50: a new size keeps the first row of the page, Value shows the item range and Status announces it.

PaginationPageSize.tsx
import * as React from 'react'
import { Pagination } from '@hamur/react/pagination'

export default function PaginationPageSizeExample() {
  const [page, setPage] = React.useState(5)
  const [pageSize, setPageSize] = React.useState(10)
  const [reason, setReason] = React.useState('none')
  return (
    <div className="stack">
      <Pagination.Root
        className="pagination pagination-compact"
        itemCount={95}
        value={page}
        onValueChange={(next, details) => {
          setPage(next)
          setReason(details.reason)
        }}
        pageSize={pageSize}
        onPageSizeChange={setPageSize}
        labels={{ pageSize: 'Rows per page' }}
      >
        <label className="pagination-page-size-label">
          Rows per page
          <Pagination.PageSize className="pagination-page-size" options={[10, 20, 50]} />
        </label>
        <Pagination.Value className="pagination-value">
          {({ start, end, itemCount }) => `Showing ${start}–${end} of ${itemCount}`}
        </Pagination.Value>
        <Pagination.Previous className="pagination-control">‹</Pagination.Previous>
        <Pagination.Next className="pagination-control">›</Pagination.Next>
        <Pagination.Status />
      </Pagination.Root>
      <p className="muted">
        Page: {page}. Page size: {pageSize}. Last reason: {reason}
      </p>
    </div>
  )
}

Roving

focusMode="roving": one Tab stop for the controls; the arrow keys, Home and End move between them, and the text next to them says so.

PaginationRoving.tsx
import { Pagination } from '@hamur/react/pagination'

export default function PaginationRovingExample() {
  return (
    <div className="stack">
      <Pagination.Root className="pagination" totalPages={10} defaultValue={5} focusMode="roving">
        <ul className="pagination-list">
          <li>
            <Pagination.Previous className="pagination-control">‹</Pagination.Previous>
          </li>
          <Pagination.Collection>
            {(entry) => (
              <li>
                {entry.type === 'page' ? (
                  <Pagination.Item value={entry.value} className="pagination-item">
                    {entry.value}
                  </Pagination.Item>
                ) : (
                  <Pagination.Ellipsis className="pagination-ellipsis" />
                )}
              </li>
            )}
          </Pagination.Collection>
          <li>
            <Pagination.Next className="pagination-control">›</Pagination.Next>
          </li>
        </ul>
      </Pagination.Root>
      <p className="muted">
        One Tab stop for the pages. The arrow keys, Home and End move between them; Enter or Space
        opens the focused page.
      </p>
    </div>
  )
}

Unknown Total

No total, as with a cursor: hasNextPage is true until the fourth page, Value reads "Page 1" without "of", and there is no Last.

PaginationUnknownTotal.tsx
import * as React from 'react'
import { Pagination } from '@hamur/react/pagination'

// A cursor-paged feed: the server says whether more pages follow, never how many there are.
const LOADED_PAGES = 4

export default function PaginationUnknownTotalExample() {
  const [page, setPage] = React.useState(1)
  return (
    <div className="stack">
      <Pagination.Root
        className="pagination"
        value={page}
        onValueChange={setPage}
        hasNextPage={page < LOADED_PAGES}
      >
        <ul className="pagination-list">
          <li>
            <Pagination.Previous className="pagination-control">‹</Pagination.Previous>
          </li>
          <Pagination.Collection>
            {(entry) => (
              <li>
                {entry.type === 'page' ? (
                  <Pagination.Item value={entry.value} className="pagination-item">
                    {entry.value}
                  </Pagination.Item>
                ) : (
                  <Pagination.Ellipsis className="pagination-ellipsis" />
                )}
              </li>
            )}
          </Pagination.Collection>
          <li>
            <Pagination.Next className="pagination-control">›</Pagination.Next>
          </li>
        </ul>
        <Pagination.Value className="pagination-value" />
      </Pagination.Root>
      <p className="muted">{page < LOADED_PAGES ? 'More results follow.' : 'No more results.'}</p>
    </div>
  )
}

API reference

31 props across 11 parts, in React syntax. className, style, render are listed once, under Shared props.

CollectionPagination.Collection

Calls its function child once per entry of the page range: a page, or an ellipsis. Entries are keyed by page, so a pressed item keeps its element and focus when the range moves. Doesn’t render its own element.

Collection props
PropTypeDefault
childrenrequiredRenders one entry: { type: 'page', value, key } or(entry: PaginationEntry, index: number) => React.ReactNode—

EllipsisPagination.Ellipsis

Stands for the pages the range hides, … unless it has children. It is hidden from assistive technology. Renders a <span>.

Ellipsis data attributes
Data attributeDescription
data-positionWhether the hidden pages are before or after the current page.

FirstPagination.First

Goes to the first page. It is aria-disabled, not disabled, on the first page, so it keeps focus., or an <a> element when the root has getPageHref. Renders a <button>.

First props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the control stays focusable when it is disabled, with aria-disabled.booleantrue

ItemPagination.Item

Goes to one page. The item of the current page has aria-current="page" and keeps its href in link mode., or an <a> element when the root has getPageHref. Renders a <button>.

Item props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
valuerequiredThe page the item goes to, 1-based.number—
Item data attributes
Data attributeDescription
data-activePresent on the item of the current page.
data-disabledPresent when the pagination is disabled.

LastPagination.Last

Goes to the last page. It is aria-disabled, not disabled, on the last page, so it keeps focus. Renders nothing while the number of pages is unknown., or an <a> element when the root has getPageHref. Renders a <button>.

Last props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the control stays focusable when it is disabled, with aria-disabled.booleantrue

NextPagination.Next

Goes to the next page. It is aria-disabled, not disabled, on the last page, so it keeps focus., or an <a> element when the root has getPageHref. Renders a <button>.

Next props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the control stays focusable when it is disabled, with aria-disabled.booleantrue

PageSizePagination.PageSize

Chooses the number of items per page. A change keeps the first item of the current page visible: the page moves with reason page-size-change. with one <option> per size, unless it has children. Renders a <select>.

PageSize props
PropTypeDefault
optionsThe sizes to choose from.readonly number[]—

PreviousPagination.Previous

Goes to the previous page. It is aria-disabled, not disabled, on the first page, so it keeps focus., or an <a> element when the root has getPageHref. Renders a <button>.

Previous props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the control stays focusable when it is disabled, with aria-disabled.booleantrue

RootPagination.Root

Groups all parts of the pagination: a navigation landmark of the controls of a paged list. Pages are 1-based. Renders a <nav>.

Root props
PropTypeDefault
valueThe current page, 1-based.number—
defaultValueThe page when the pagination is initially rendered.number1
onValueChangeEvent handler called when the page changes.((value: number, eventDetails: PaginationRootChangeEventDetails) => void)—
totalPagesThe number of pages.number—
itemCountThe number of items.number—
pageSizeThe number of items per page.number—
defaultPageSizeThe number of items per page when the pagination is initially rendered.number10
onPageSizeChangeEvent handler called when the page size changes from Pagination.PageSize.((pageSize: number, eventDetails: { reason: "page-size-change"; event: Event; cancel: () => void; allowPropagation: () => void; isCanceled: boolean; isPropagationAllowed: boolean; trigger: Element | undefined; }) => void)—
siblingCountThe number of pages shown on each side of the current page.number1
boundaryCountThe number of pages always shown at each end.number1
fixedItemsWhether the range keeps the same number of entries on every page, so the controls do notbooleantrue
hasNextPageWhether a next page exists while the number of pages is unknown.booleantrue
getPageHrefBuilds the href of a page.((page: number) => string)—
disabledWhether the component should ignore user interaction.booleanfalse
focusModeHow focus moves between the controls: 'tab' makes each a Tab stop; 'roving' gives onePaginationFocusMode'tab'
orientationThe orientation the arrow keys follow in focusMode="roving".Orientation'horizontal'
loopFocusWhether the arrow keys wrap from the last control to the first in focusMode="roving".booleantrue
labelsThe texts, in English by default.Partial<PaginationLabels>—
Root data attributes
Data attributeDescription
data-disabledPresent when the pagination is disabled.

StatusPagination.Status

A polite live region that announces the new page, or the new item range when the root has itemCount, after each page or page-size change. It is empty on the first render and visually hidden. Renders a <div>.

ValuePagination.Value

The current page as text: "Page 3 of 10", or "Page 3" while the number of pages is unknown. Plain text, not a live region. Renders a <span>.

Value props
PropTypeDefault
childrenThe text, or a function of the page, the number of pages, the page size and the item range.React.ReactNode | ((values: PaginationValueValues) => React.ReactNode)—

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
TabMoves to the next control: by default every control is a Tab stopItem
EnterSpaceOn a button: opens its page and keeps focus on it while the range moves; on a link, Enter follows itItem
EnterSpaceNothing: Previous on the first page and Next on the last stay focusable and do not change the pagePrevious
ControlMetaShiftAltOn a link: opens it in a new tab or window as the browser does, and the page does not changeItem
TabfocusMode="roving"Enters and leaves the controls as one Tab stop, on the current pageRoot
ArrowLeftArrowRightWith focusMode="roving": moves focus between the controls, skipping the ellipsis; the page staysRoot
HomeEndWith focusMode="roving": moves focus to the first or the last controlRoot
ArrowUpArrowDownWith focusMode="roving" and orientation="vertical": move focus instead of the left and right arrowsRoot

Accessibility

What the parts set
PartSetsSo that
Roota nav named "Pagination" (labels.root, or your aria-label or aria-labelledby)the controls are a landmark a screen reader lists and jumps to; name each one when a page has two
Itemaria-current="page" on the current page only, aria-label "Page 4"; in link mode the current page keeps its hrefthe current page is read as "current page, Page 4", and every page has a name beyond its number
Previousat the first page aria-disabled="true", focusable; in link mode no href and no rel, with role="link"focus stays on the control when it reaches its bound, and a disabled link is announced as dimmed, not lost
Nextrel="next" on an enabled link, rel="prev" on Previousthe browser and crawlers know the order of the pages
Ellipsisaria-hidden="true", … unless it has childrena screen reader skips it; the pages around it already say which numbers are missing
Statusrole="status", aria-live="polite", aria-atomic, visually hidden; empty on the first render"Page 5 of 10" or "Showing 41–60 of 95" is read after a change, without moving focus (WCAG 4.1.3)
PageSizea native select named "Items per page" (labels.pageSize)the platform's own picker opens on touch and with the keyboard, and the arrow keys stay the select's

Focus order

  1. Each control is a Tab stop in DOM order: First, Previous, the pages, Next, Last. The ellipsis is never focused. Nothing takes focus on mount.
  2. After a press in button mode, focus stays on the pressed control: the pages are keyed by number, so the element of page 7 stays when the range moves, and a control at its bound stays focusable.
  3. With focusMode="roving" the controls are one Tab stop, the current page first; the arrow keys, Home and End move between them and wrap unless loopFocus is false. PageSize stays a Tab stop of its own.
  4. In link mode with a full page load, focus is the browser's on the new page.