Pagination
Page controls for a long result set. Presentational only — you decide which page numbers to show, using the paginationRange() helper shipped alongside.
Added 14 October 2026
Anchor or button? This is the decision
The parts render an <a> by default, and that is right only when each page has a real URL
(?page=3, /sites/page/3) — then the browser’s own affordances make sense: open in a new tab, copy the
link, back button.
When paging is client-side state, use a button. Both frameworks support it:
<PaginationLink render={<button type="button" />} isActive={p === page} onClick={() => setPage(p)}>
{p}
</PaginationLink>
<button hlmPaginationLink type="button" [isActive]="p === page()" (click)="go(p)">{{ p }}</button>
<a href="#"> with preventDefault is the wrong answer to that case, and it is worth knowing exactly why:
it announces as a link, joins the screen reader’s links list, offers open-in-new-tab (yielding #), and
answers Enter but not Space — while behaving as a button. That mismatch is the tell. [a11y]
Announce the page change
Activating a page control replaces the whole result set while focus stays on the control. Nothing about that
is perceivable to a screen-reader user unless you say so — WCAG 4.1.3. Put one polite live region next to the
control; <output> carries an implicit role="status", so it needs no ARIA:
<output className="text-xs text-muted-foreground">Page {page} of {pageCount} — {total} results</output>
One region per control. Not a toast per click.
Which numbers to show
The components do not know your page count. paginationRange() — shared by both frameworks from lib/, and
re-exported from the same import as the parts — decides the window:
paginationRange({page: 5, pageCount: 20}); // [1, GAP, 4, 5, 6, GAP, 20]
paginationRange({page: 4, pageCount: 10}); // [1, 2, 3, 4, 5, GAP, 10]
paginationRange({page: 10, pageCount: 20, siblings: 2});
siblings(default 1) — pages either side of the current one.boundaries(default 1) — pages pinned at each end.- A gap is only emitted where it replaces more than one page; a single hidden page is rendered instead, because collapsing it costs the same width and removes a click target. So the result length varies.
- Out-of-range input is clamped, not rejected —
page > pageCounthappens routinely when you delete the last row on the last page. - Compare slots against the exported
PAGINATION_GAP, never a string literal.
The slot list is not identity-stable. Its length changes as you page, so in Angular do not
track $index: positional tracking recycles the anchors and rewrites the focused control’s meaning under the
user. A gap can appear twice, so the value alone is not unique either — use track slot + '-' + $index. React
keys by page number and by gap position for the same reason.
Accessibility
- The
<nav>getsaria-label="pagination"by default. Override it when more than one pagination exists on a page — identical landmark names are indistinguishable in a landmark list. [a11y] - The active link carries
aria-current="page"automatically. - The ellipsis is
aria-hidden: skipped pages are not actionable, and the surrounding numbers convey the range. It deliberately carries no screen-reader text — ansr-onlylabel inside anaria-hiddensubtree reaches nobody, which is a bug upstream ships and we removed. - Previous/Next hide their label below
sm:and keep anaria-label, so they stay named when only the chevron shows. Setdisabledat the ends rather than hiding them; a control that vanishes moves everything else under the pointer.
Deviation from upstream worth knowing
Upstream signals the current page only by swapping the button variant from ghost to outline — and those
differ solely in border colour, which computes to 1.88:1 light / 1.59:1 dark in this token set. That is below
the 3:1 a state indicator needs and makes colour the sole carrier. We add a bg-secondary fill and
font-semibold, so the current page survives low vision and greyscale. Don’t “restore” the upstream look.
Never
- A pagination control with no page-change announcement.
<a href="#">for client-side paging.- Rendering the whole page list when there are hundreds — that is what the window is for.
- Two paginations on one page sharing the default label.