ComponentStandalone

Pagination

Numbered page navigator. Two-way binds page and pageSize via signal models.

Installation

import { WrPagination } from 'ngwr/pagination';

@Component({ imports: [WrPagination] })
export class MyComponent {}

Basic usage

1 2 3 4 5 12
<wr-pagination [total]="120" [(page)]="page" />

Sizes

Three steps — sm / md / lg.

1 2 3 4 5 12
1 2 3 4 5 12
1 2 3 4 5 12
<wr-pagination [total]="120" [(page)]="page" size="sm" />
<wr-pagination [total]="120" [(page)]="page" size="md" />
<wr-pagination [total]="120" [(page)]="page" size="lg" />

Shape

square flattens cell corners for a tighter numeric grid (e.g. inside a data table).

1 2 3 4 5 12
1 2 3 4 5 12
<wr-pagination [total]="120" [(page)]="page" shape="rounded" />
<wr-pagination [total]="120" [(page)]="page" shape="square" />

With total + size changer

Combine showTotal and showSizeChanger for full controls. align='end' pushes to the right.

1–10 of 320
1 2 3 4 5 32
<wr-pagination
  [total]="320"
  [(page)]="page"
  [(pageSize)]="size"
  showTotal
  showSizeChanger
  align="end"
/>

Narrow container (container query)

With responsive, the numbered strip collapses to a compact ‹ page / total › pager when the control's own box is too narrow — a container query on its own width, not the viewport. The box below is fixed at 260px.

1 2 3 4 5 12
<div style="width: 260px"><wr-pagination responsive [total]="120" [(page)]="page" /></div>

Server-side paging

total has no companion loading input, so 0 is ambiguous between an empty list and a request in flight — and the second is the ordinary state of a server-paged host. While total is at or below 0 the pager holds the page it was given: it does not pull it DOWN into range, and emits no pageChange for it. Clamping resumes once the total settles. The lower bound is the exception and is never conditional — a page below 1 is floored, and that one does emit.

page 1 — total 51
1 2 3 4 5 6
<wr-pagination
  [total]="total()"
  [page]="page()"
  (pageChange)="page.set($event)"
/>
private readonly http = inject(HttpClient);

readonly page = signal(1);
readonly size = signal(10);

// The params function builds a fresh object literal every run, so it
// is never reference-equal to the last one: the resource drops its
// value the moment the page changes and regains it only when the
// response lands. That gap is what total reads as 0.
private readonly result = rxResource({
  params: () => ({ page: this.page(), size: this.size() }),
  stream: ({ params }) => this.http.get<{ data: User[]; total: number }>('/api/users', { params }),
});

readonly rows = computed(() => this.result.value()?.data ?? []);
readonly total = computed(() => this.result.value()?.total ?? 0);

That is the wiring the Angular docs lead you to, and the gap is not avoidable in it: a params function returning an object literal is never reference-equal to its predecessor, so the resource discards the previous payload the instant the page changes. The demo above stands in for the round trip — clicking a page drops total to 0 for 900 ms. The page you picked is kept and reported once; before this contract the pager clamped back to 1 and emitted a second pageChange the host could not tell from a click.

What that state costs is the strip: with total at 0 there is one page to render, so a host holding any page past the first has no cell carrying aria-current until the total settles. On a first load, where the held page is 1, the single cell is current as usual. The showTotal label reports an empty range rather than a nonsensical one — it special-cases an empty total and reads “0-0 of 0”, which is wrong about a list of 51 but not backwards, as it was before this contract. The arrows stay usable throughout, because they compare against the range rather than for equality with its ends: past the end next renders disabled, and previous stays enabled and steps back into range rather than to page() - 1.

The lower bound is not conditional. No value of total makes page 0 correct, so a page below 1 is floored to 1 whatever the total reads — and unlike the upper bound, that correction is written back and reported.

Page size is the host's decision

Choosing a size emits pageSizeChange and stops. The component does not clamp the page against the new size: pageSizeChange is emitted synchronously, so by the next statement the host has already run its own policy and that write has not reached the model yet — the clamp would compute from a page already superseded and overwrite the host's. Whatever the host does with the page stands.

<wr-pagination
  showSizeChanger
  [total]="total()"
  [page]="page()"
  (pageChange)="page.set($event)"
  [pageSize]="size()"
  (pageSizeChange)="onSizeChange($event)"
/>
// Reset — one request, and it is for a page that exists.
onSizeChange(next: number): void {
  this.size.set(next);
  this.page.set(1);
}
// Keep the page — nothing corrects it while total reads 0, so this
// can request a page the new size no longer has. The response is
// what corrects it.
onSizeChange(next: number): void {
  this.size.set(next);
}

Both policies are supported and they cost different things. Resetting issues one request, and it is for a page that exists. Keeping the page is the smaller edit and the worse request: the size change invalidates the payload too, so total reads 0, the guard above declines to correct anything, and the request that goes out may be for a page the new size no longer has — from page 6 at size 10, choosing 25 asks for page 6 of 3, and the correction lands only when that answer does. That is an accepted cost of never writing back from transient state, not something the component hides.

Keeping the last good total

Everything above follows from total dipping to 0. A linkedSignal over the resource that falls back to its previous value keeps the last number up while the next request is out, so it never dips at all.

// The total from the last response that carried one, held
// across the next request.
readonly total = linkedSignal<number | undefined, number>({
  source: () => this.result.value()?.total,
  computation: (next, previous) => next ?? previous?.value ?? 0,
});

With that in place the strip never collapses, aria-current stays on the page you are on, and the clamp keeps working throughout — so a page the new size no longer has is corrected on the spot instead of after the round trip. It is the better trade rather than the absence of one: what is on screen while the request is in flight is now the PREVIOUS response's count, so showTotal reports a number that no longer describes the list, and a size change landing together with a shrinking total clamps against the old count.

Keyboard, roles and accessible names

Every cell is its own tab stop. There is no roving focus and the arrow keys do nothing here — a pager is a list of links to places, not a composite widget with one active item, so Tab reaches each cell and Enter or Space activates it. The host is a role="navigation" landmark named from the pagination.label key; the previous / next buttons and each number carry their own accessible name, which is what an end-to-end test should match on.

<!-- What `<wr-pagination [total]="120" [(page)]="page" />` renders, in the
     shape a Playwright or Testing Library locator sees. Tab reaches every
     cell; Enter and Space activate the focused one; arrow keys do nothing. -->
<wr-pagination role="navigation" aria-label="Pagination" class="wr-pagination wr-pagination--md …">
  <div class="wr-pagination__inner">
    <div class="wr-pagination__nav">
      <wr-btn role="button" tabindex="0" aria-label="Previous page" class="wr-pagination__nav-btn">…</wr-btn>
      <wr-btn role="button" tabindex="0" aria-label="Go to page 1" aria-current="page" class="wr-pagination__page">1</wr-btn>
      <wr-btn role="button" tabindex="0" aria-label="Go to page 2" class="wr-pagination__page">2</wr-btn>
      <span class="wr-pagination__ellipsis">…</span>
      <wr-btn role="button" tabindex="0" aria-label="Next page" class="wr-pagination__nav-btn">…</wr-btn>
    </div>
  </div>
</wr-pagination>

<!-- `<wr-btn>` is a custom element, so the role and tabindex are attributes it
     writes rather than native button semantics: query by role and name.

       page.getByRole('button', { name: 'Go to page 2' }).click();
       expect(page.getByRole('button', { name: 'Go to page 2' }))
         .toHaveAttribute('aria-current', 'page');

     The current cell is the one carrying `aria-current="page"`. A disabled
     arrow at the end of the range carries `aria-disabled="true"` and loses its
     `tabindex`; the `disabled` attribute is written too, but it is inert on a
     custom element — there is no `disabled` DOM property to read. -->

The names come from the i18n catalog, so they follow the app's locale — match on the resolved text, or override the key, rather than hard-coding English in a test that a translated build will fail. The three overridable inputs are label, prevLabel and nextLabel; the numbered cells read pagination.goToPage and the size changer pagination.itemsPerPage.

The class names are public API and stable — .wr-pagination__page for a numbered cell, .wr-pagination__nav-btn for the two arrows, .wr-pagination__ellipsis for a gap, .wr-pagination__size for the size changer. A unit test has a harness instead: WrPaginationHarness from ngwr/pagination/testing.

API

NameDescriptionTypeDefault
pageCurrently displayed page (1-based). Two-way bindable. page rather than currentPage since v14, so this and <wr-table> — which renders one of these in its own footer — spell the two concepts they share one way each. It also names the output: a model() called X forces (XChange), so the old name produced (currentPageChange) against the table's (pageChange).number1
pageSizeItems per page. Two-way bindable.number10
totalTotal item count across all pages.number0
pageSizeOptionsOptions shown in the page-size dropdown.readonly number[][10, 20, 50, 100]
showSizeChangerRender the page-size dropdown.booleanfalse
showTotalRender the "X–Y of Z" total label.booleanfalse
alignHorizontal alignment.WrPaginationAlign'start'
sizeSize variant — cascades to every internal button.WrPaginationSize'sm'
shapeCell corner treatment.WrPaginationShape'rounded'
disabledDisable interaction.booleanfalse
responsiveCollapse to a compact ‹ page / total › pager when the control's own box is too narrow for the full numbered strip (a container query on its own width, not the viewport).booleanfalse
prevLabelPrevious-page button aria-label. Falls back to pagination.prev.string | nullnull
nextLabelNext-page button aria-label. Falls back to pagination.next.string | nullnull
itemsPerPageLabel"Items per page" label. Falls back to pagination.itemsPerPage.string | nullnull
labelAccessible name for the role="navigation" host. Falls back to pagination.label.string | nullnull

CSS variables

Custom properties ngwr/pagination publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.

VariableDefaultDeclared on
--wr-pagination-cell1.75rem only under .wr-pagination--sm — unset elsewhere.wr-pagination--sm +2 variant overrides