# Pagination

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

Source: https://ngwr.dev/reference/components/pagination  
Kind: Component, Standalone

## Installation

```angular-ts
import { WrPagination } from 'ngwr/pagination';

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

## Basic usage

```html
<wr-pagination [total]="120" [(page)]="page" />
```

## Sizes

Three steps — `sm` / `md` / `lg`.

```html
<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).

```html
<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.

```html
<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.

```html
<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.

```html
<wr-pagination
  [total]="total()"
  [page]="page()"
  (pageChange)="page.set($event)"
/>
```

```typescript
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.

```angular-html
<wr-pagination
  showSizeChanger
  [total]="total()"
  [page]="page()"
  (pageChange)="page.set($event)"
  [pageSize]="size()"
  (pageSizeChange)="onSizeChange($event)"
/>
```

```typescript
// Reset — one request, and it is for a page that exists.
onSizeChange(next: number): void {
  this.size.set(next);
  this.page.set(1);
}
```

```typescript
// 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.

```typescript
// 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.

```angular-html
<!-- 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

| Name | Description | Type | Default |
| --- | --- | --- | --- |
| `page` | Currently 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)`. | `number` | `1` |
| `pageSize` | Items per page. Two-way bindable. | `number` | `10` |
| `total` | Total item count across all pages. | `number` | `0` |
| `pageSizeOptions` | Options shown in the page-size dropdown. | `readonly number[]` | `[10, 20, 50, 100]` |
| `showSizeChanger` | Render the page-size dropdown. | `boolean` | `false` |
| `showTotal` | Render the "X–Y of Z" total label. | `boolean` | `false` |
| `align` | Horizontal alignment. | `WrPaginationAlign` | `'start'` |
| `size` | Size variant — cascades to every internal button. | `WrPaginationSize` | `'sm'` |
| `shape` | Cell corner treatment. | `WrPaginationShape` | `'rounded'` |
| `disabled` | Disable interaction. | `boolean` | `false` |
| `responsive` | Collapse 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). | `boolean` | `false` |
| `prevLabel` | Previous-page button aria-label. Falls back to `pagination.prev`. | `string \| null` | `null` |
| `nextLabel` | Next-page button aria-label. Falls back to `pagination.next`. | `string \| null` | `null` |
| `itemsPerPageLabel` | "Items per page" label. Falls back to `pagination.itemsPerPage`. | `string \| null` | `null` |
| `label` | Accessible name for the `role="navigation"` host. Falls back to `pagination.label`. | `string \| null` | `null` |

## 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.

| Variable | Default | Declared on |
| --- | --- | --- |
| `--wr-pagination-cell` | `1.75rem` only under `.wr-pagination--sm` — unset elsewhere | `.wr-pagination--sm` +2 variant overrides |
