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
React
Codecomponents/react/pagination-demo.tsx
import {
PAGINATION_GAP,
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
paginationRange,
} from '@stroeer-labs/design-library/react';
import {useState} from 'react';
const PAGE_COUNT = 20;
// Paging here is client-side state, so the controls render as BUTTONS via `render`, not anchors. An
// `<a href="#">` with preventDefault announces as a link, joins the screreader's link list, offers
// open-in-new-tab, and responds to Enter but not Space — while behaving as a button. Use the anchor form only
// when each page has a real URL. Both are supported; the styling is element-agnostic.
export function PaginationDemo() {
const [page, setPage] = useState(1);
const slots = paginationRange({page, pageCount: PAGE_COUNT});
const go = (target: number) => () => setPage(Math.min(Math.max(target, 1), PAGE_COUNT));
return (
<div className="space-y-6">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Live — window from <code className="font-mono text-xs">paginationRange()</code>
</p>
<Pagination aria-label="Site results pagination">
<PaginationContent>
<PaginationItem>
<PaginationPrevious render={<button type="button" />} onClick={go(page - 1)} disabled={page === 1} />
</PaginationItem>
{slots.map((slot, index) =>
slot === PAGINATION_GAP ? (
// A gap has no identity of its own; its position in the window is the identity.
<PaginationItem key={`gap-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={slot}>
<PaginationLink render={<button type="button" />} isActive={slot === page} onClick={go(slot)}>
{slot}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext render={<button type="button" />} onClick={go(page + 1)} disabled={page === PAGE_COUNT} />
</PaginationItem>
</PaginationContent>
</Pagination>
{/* Paging swaps the whole result set while focus stays on the control, so the change has to be
announced — WCAG 4.1.3. `<output>` carries an implicit role="status", so it is a polite live
region without the explicit role. One region beside the control, not a toast per click. */}
<output className="text-xs text-muted-foreground">
Page {page} of {PAGE_COUNT}
</output>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">URL-backed pages — anchors are correct here</p>
<Pagination aria-label="Example of anchor-based pagination">
<PaginationContent>
{paginationRange({page: 2, pageCount: 5}).map((slot) => (
<PaginationItem key={slot}>
<PaginationLink href={`?page=${slot}`} isActive={slot === 2}>
{slot}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
</div>
</div>
);
}Angular
Codecomponents/angular/pagination-demo.ts
import {ChangeDetectionStrategy, Component, computed, signal} from '@angular/core';
import {HlmPaginationImports, PAGINATION_GAP, paginationRange} from '@stroeer-labs/design-library/angular';
// Mirrors the React pagination demo, including the same shared `paginationRange()` from lib/ — the point of the
// helper living there rather than being written twice.
//
// Paging here is client-side state, so the controls are BUTTONS, not anchors: `<a href="#">` with preventDefault
// announces as a link, joins the screen reader's link list, and answers Enter but not Space while behaving as a
// button. The anchor form is right only when each page has a real URL; both are supported.
//
// `track` uses a composite key, not `$index`: the slot list changes length as you page
// (`1 2 3 4 5 … 20` -> `1 … 8 9 10 … 20`), so positional tracking recycles the anchors and rewrites the focused
// control's meaning underneath the user. A gap can appear twice, so the slot value alone is not unique either.
@Component({
selector: 'dl-pagination-demo',
standalone: true,
imports: [HlmPaginationImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="space-y-6">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">
Live — window from <code class="font-mono text-xs">paginationRange()</code>
</p>
<nav hlmPagination aria-label="Site results pagination">
<ul hlmPaginationContent>
<li hlmPaginationItem>
<button hlmPaginationPrevious type="button" [disabled]="page() === 1" (click)="go(page() - 1)"></button>
</li>
@for (slot of slots(); track slot + '-' + $index) {
<li hlmPaginationItem>
@if (slot === gap) {
<hlm-pagination-ellipsis />
} @else {
<button hlmPaginationLink type="button" [isActive]="slot === page()" (click)="go(+slot)">{{ slot }}</button>
}
</li>
}
<li hlmPaginationItem>
<button hlmPaginationNext type="button" [disabled]="page() === pageCount" (click)="go(page() + 1)"></button>
</li>
</ul>
</nav>
<!-- Paging swaps the whole result set while focus stays on the control, so it must be announced
(WCAG 4.1.3). \`<output>\` has an implicit role="status", so it is a polite live region already. -->
<output class="text-xs text-muted-foreground">Page {{ page() }} of {{ pageCount }}</output>
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">URL-backed pages — anchors are correct here</p>
<nav hlmPagination aria-label="Example of anchor-based pagination">
<ul hlmPaginationContent>
@for (slot of shortRange; track slot) {
<li hlmPaginationItem>
<a hlmPaginationLink href="?page={{ slot }}" [isActive]="slot === 2">{{ slot }}</a>
</li>
}
</ul>
</nav>
</div>
</div>
`,
})
export class PaginationDemoComponent {
protected readonly gap = PAGINATION_GAP;
protected readonly pageCount = 20;
protected readonly page = signal(1);
protected readonly slots = computed(() => paginationRange({page: this.page(), pageCount: this.pageCount}));
protected readonly shortRange = paginationRange({page: 2, pageCount: 5});
protected go(target: number): void {
this.page.set(Math.min(Math.max(target, 1), this.pageCount));
}
}