Skip to content
Folio

Navigation

Pagination

Numbered pages, with the middle elided.

Usage

TSX
import { Pagination } from '@misoto22/design'

Notes

Numbered pagination.

The current page is marked by one filled pill that TRAVELS between the numbers rather than by a background switching off on one and on on another. Two backgrounds cross-fading reads as two things changing; a shape moving reads as the one thing that actually did.

A <nav> wrapping a list, and the current page is a <button aria-current> rather than a styled <span> — a reader jumping by landmark needs to find the control, and a reader on the current page needs to be told they are already there.

Renders nothing at one page or fewer. A pager for a single page is furniture.

Every string a reader hears is a prop. The chevrons carry no text, so previousLabel and nextLabel are the whole of those two controls' names, and pageLabel is a function because "Page 3" is a phrase whose parts move around between languages.

Anatomy

Pagination anatomy
ElementDescription
NavrequiredA <nav> named by label, “Pagination” by default. Nothing else here is a landmark, so this is how a reader jumps to the pager rather than scrolling to it.
Step buttonsrequiredPrevious and Next, pill-cornered at --control-h-sm, named by previousLabel and nextLabel — “Previous page” and “Next page” until a call site says otherwise. Each is disabled at its end of the range, which takes it out of the tab order rather than leaving a control that does nothing.
Page listrequiredAn <ol> of numbers, each a <button> named by pageLabel — “Page N” by default — and carrying aria-current on the one you are on.
Travelling pillOne aria-hidden fill, measured from the selected button and moved with a transform rather than two grounds cross-fading. It is absent until the first measurement lands, and holds still under prefers-reduced-motion.
EllipsisAn aria-hidden <li> wherever the sequence skips more than one page. A single skipped page is printed instead — “1 … 3” is longer than “1 2 3” and says less.

Best practices

Do

  • Move page in the same state update that fetches: it is fully controlled, so a handler that loads the next page without setting page leaves the pager marking the page the reader just left.
  • Raise siblings rather than lowering it — below 2 × siblings + 5 pages every page is printed anyway, so the prop does nothing on a short list and is the only lever you have on a long one.
  • Import paginationRange when something else has to agree with the pager: it is exported and pure, which is how a server-rendered summary and this component end up describing one window instead of two.
  • Let the surrounding row collapse: the component returns null at one page or fewer, so a footer built to a fixed height shows an empty strip on the day the list gets short.

Don’t

  • Do not put it in a compact region and call it a touch target: the pills are --control-h-sm, 36px comfortable and 30px under data-density="compact", set 4px apart — well under the 44px WCAG 2.5.5 asks of a pointer target.
  • Do not expect pageLabel to change what is printed. It names the control for a screen reader and nothing else; the button still shows the Western digit it was handed, so a locale that writes its numerals differently has to format them at the call site as well.

Examples

default

Twenty pages, fully controlled. Move page in the same state update that fetches: a handler that loads the next page without setting page leaves the pager marking the page the reader just left. The current page is a button with aria-current rather than a styled span, because a reader jumping by control has to be able to find it — and the filled pill travels between the numbers rather than one ground switching off while another switches on, which reads as the one thing that actually changed. It renders nothing at one page or fewer, so a footer built to a fixed height shows an empty strip on the day the list gets short.

page 1 of 20

the edges

The same twenty-four pages seen from both ends and from the middle, which is where a pager is usually wrong. Previous is disabled at page one and Next at the last, which takes each out of the tab order rather than leaving a control that does nothing when pressed. The ellipsis appears only where the sequence skips more than one page — a single skipped page is printed instead, because 1 … 3 is longer than 1 2 3 and tells the reader less. Three pagers means three nav landmarks, so each one here is named.

At the first page

In the middle, elided on both sides

At the last page

a wider window

siblings is how many numbered pages sit either side of the current one. Raise it rather than lowering it: below 2 × siblings + 5 pages every page is printed anyway, so the prop does nothing on a short list and is the only lever you have on a long one. Both pagers here share one page, so a click on either moves both. A wider window is more targets at the same small size and not a better one — the pills are --control-h-sm, 36px comfortable and 30px under compact density, which is well under the 44px WCAG 2.5.5 asks of a pointer target.

One sibling, the default

Two siblings

Parts

Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.

paginationRange

Builds the visible page list: always the first and last page, a window around the current one, and an ellipsis wherever the sequence skips.

Returned as numbers and a literal ellipsis rather than as pre-rendered nodes, so the shape is testable without a DOM — the off-by-one at the window edges is the whole difficulty of this component.

Takes no props of its own.

Keyboard

Pagination keyboard interactions
KeyDoes
TabReaches every control, including the current page.
EnterSpaceGoes to that page.

Accessibility

  • The current page is a button with aria-current, not a styled span — a reader jumping by control needs to find it.
  • Renders nothing at one page. A pager for a single page is furniture.
  • Every string a reader hears is a prop: the two chevrons by name, each numbered page through pageLabel — a function rather than a template, because “Page 3” is a phrase whose parts move around between languages.