Skip to content
Navigation

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));
    }
}