CngxPaginator
projects/ui/paginator/paginator.component.ts
Import#
import { CngxPaginator } from '@cngx/ui/paginator'
Description#
Declarative, skinnable pagination organism.
A thin shell over the CngxPaginate brain (applied as a hostDirective), assembled by the
consumer from projected segment parts in DOM order - no show* config inputs.
skin / density are single paint attributes reflected onto [data-skin] / [data-density]. \
Two-way [(pageIndex)] / [(pageSize)]:
the brain's controlled inputs are aliased through hostDirectives (cngxPageIndex -> pageIndex, cngxPageSize -> pageSize);
the shell owns the matching pageIndexChange / pageSizeChange outputs and is their single emitter,
wired through the shared connectPaginateEmit bridge (identical to CngxIncrementalList).
The bridge feeds each output once from two paths:
(1) a subscription forwards the brain's nav-only pageChange /
pageSizeChange - the only signal that captures a controlled-mode setPage,
because the brain's pageIndex() stays pinned to the input until the
consumer feeds it back;
(2) an effect() reads the effective pageIndex() / pageSize() and covers a total-shrink clamp the nav-only
output misses.
The brain output is forwarded, NOT aliased through
hostDirectives:
a raw alias would bypass the shared guard and double-emit
on uncontrolled navigation.
All ARIA attributes are signal-driven. aria-busy has a single owner here.
Metadata#
Host#
Providers#
CNGX_PAGINATOR_HOST- useExisting
CngxPaginate
Dependencies#
CNGX_PAGINATOR_ANNOUNCER_FACTORYinject()announcerLive-region message source - mounted onto the cngxLiveRegion span in the
template. Built through the swap token so a consumer can wrap the
busy / settle / page-change derivation without forking the shell.
Relationships
Index#
Inputs#
string | undefinedAccessible name for the navigation landmark. Overrides the config default.
undefined, { alias: 'aria-label' }CngxPaginatorDensityPrivate size preset (hit-target + type); reflected onto
[data-paginator-size], NOT the global [data-density]. Spacing is a
global concern derived from --cngx-space-*, so a root [data-density]
compacts this paginator's gaps like every other component; this input keeps
only the touch-target sizing the global scale does not yet cover.
'default'unknownReset key. When its value changes (after the initial render) the paginator
jumps to the first page - bind the sort / filter / search value a result set
depends on so a narrowed result never strands the user on a now-empty page.
Bind a primitive or a computed; an inline array / object literal
recomputes every change-detection pass and would reset on each. Shares the
connectPaginateResetOn implementation with [cngxPaginateResetOn].
Opt into the responsive collapse; reflected onto [data-responsive]. When
set, a @container rule swaps the projected cngx-pgn-pages number row for
a cngx-pgn-status "Page n of m" readout once the paginator's own container
narrows past the collapse breakpoint. Compose both segments for it to apply.
false, { transform: booleanAttribute }Outputs#
Emits the effective page index on every change - navigation or total-shrink clamp.
Instance Properties#
unknowncontentChild(CngxPaginatorLoading, { read: TemplateRef })HostBindings#
| Binding | Expression |
|---|---|
[attr.aria-label] | resolvedAriaLabel() |
[attr.aria-busy] | paginate.isBusy() |
[attr.data-skin] | skin() |
[attr.data-paginator-size] | density() |
[attr.data-responsive] | responsive() ? '' : null |
Pagination: UX and Accessibility
Pagination splits a long result set into numbered pages and gives the user a control to move between them. It earns its keep when the data is too long to show at once but the user still needs a sense of where they are and how much is left - a search result, an admin table, a catalogue. Unlike infinite scroll, a paginator gives a stable position ("page 4 of 12") that survives a refresh, a back button, and a shared link.
This document is about the pattern, not the CNGX API. It explains when to reach for a paginator, how it differs from load-more and infinite scroll, how to make it keyboard- and screen-reader-complete, and the mistakes that surface once the happy path is left behind. For the component inputs and outputs, see the Info and API tabs.
When pagination is the right tool
Reach for a paginator when all of these hold:
- The result set is large enough that showing everything at once is slow or overwhelming.
- The user benefits from a stable position they can return to, cite, or share.
- Jumping to a specific page (first, last, page 7) is a real use, not just stepping forward.
Typical homes: data grids and admin tables, search results, catalogues, any list backed by a paged or offset-based API.
Prefer a different pattern when:
- The user only ever scans forward and position never matters - that is infinite scroll or a load-more button, not a paginator.
- The set is small enough to show in full - then pagination is friction with no payoff.
- The content is a sequential task with a start and an end - that is a stepper, not pages of the same kind of thing.
Pagination versus load-more versus infinite scroll
All three break a long list into chunks; the difference is whether position is a first-class, addressable thing.
| - | Pagination | Load-more | Infinite scroll |
|---|---|---|---|
| Position | Explicit and addressable ("page 4 of 12") | Implicit (how many loaded) | None |
| Jump to a page | Yes (first / last / any number) | No | No |
| Shareable / refresh-stable | Yes | Partly | No |
| Best for | Tables, search, catalogues | Feeds where order matters but page does not | Media / social streams |
| Footer controls stay reachable | Yes | Yes | Often pushed off-screen |
Rule of thumb: if the user would ever say "go back to page 3" or wants to know how far through they are, you want pagination. If they only ever consume forward and never cite a position, load-more or infinite scroll fits better.
Anatomy
A paginator composes from independent parts; show only the ones the context needs:
- Page row: the numbered buttons. One carries
aria-current="page". A long run truncates into an ellipsis that reveals the hidden pages. - Previous / next: step one page at a time. Disabled at the respective bound.
- First / last: jump to the boundaries. Disabled at the respective bound.
- Range readout: "21-30 of 240" - the honest count of what this page shows.
- Page-size select: how many items per page; changing it resets to the first page.
- Go-to-page / page-of-pages: type or pick a page to jump directly.
The brain that drives all of these is content-agnostic: it computes page math from the total item count and reports the active page back. The same brain paginates a table, a card grid, or a dropdown panel.
UX guidance
- Show the range, not just the page number. "21-30 of 240" tells the user how much there is and where they are; a bare "page 3" does not.
- Disable boundary controls visibly, but keep them perceivable - at the first page, previous and first are inert, and that inert state must be communicated, not silent.
- Resetting the page size returns to the first page. Jumping the user to "page 7 of a re-sized list" is disorienting; start over from the top.
- Keep the control in a predictable place (usually the footer of the list it pages) and keep it there as the data changes.
- Match the visual skin to context, not novelty. The skin is pure CSS - it changes nothing about structure, keyboard, or ARIA - so pick the one that reads best and move on.
- Plan the empty and loading states. A paginator over zero results should not show a broken "page 1 of 0"; a paginator over slow data should gate navigation, not let clicks queue.
Communicating state
Every change the paginator makes is communicated on three channels at once - visual, semantic, and to assistive tech - never by one alone.
| State | What changes | How it is communicated |
|---|---|---|
| Current page | The active page button | aria-current="page" on the active button plus a live-region "Page N of M" announcement |
| At a boundary | First/previous or next/last become inert | aria-disabled on the button (it stays focusable so the bound state is heard), not a removed control |
| Busy / loading | Navigation is gated | aria-busy on the landmark, an indeterminate progress bar, and a "Loading" / "Updated" announcement |
| Range | The first-last-of-total readout | Live text derived from the same page math, so it never drifts from the buttons |
A total-shrink that clamps the effective page (the data got smaller while the user was on a
high page) is never silent: the clamped page is both reported back to the consumer and
announced, so the user is not stranded on a page that no longer exists.
Accessibility
A11y is not a later audit pass; it is part of the design from the first implementation. Build
on semantic HTML first (native <button>s, a named nav landmark) and add ARIA only where
the semantics fall short.
Keyboard
The whole paginator must be operable from the keyboard alone.
| Key | Action |
|---|---|
| Tab | Move into the page row (lands on the active page), then on to the next control |
| Arrow keys | Move the active page button along the row |
| Home / End | Jump to the first / last page button in the row |
| Enter / Space | Activate the focused page or control |
Notes that matter in practice:
- The page row is one tab stop (roving tabindex):
Tablands on the active page and the nextTabmoves on, rather than walking through every number. - Previous / next / first / last stay in normal tab order as ordinary buttons.
- A button at its bound reports
aria-disabledbut keeps its place in the tab order, so a screen-reader user hears that the boundary is reached instead of finding the control gone. - While the bound async state is busy, navigation is a no-op - a page click cannot race an in-flight load.
Screen reader
- Give the paginator an accessible name (it is a
navlandmark) so it is not just an unnamed region in the page outline. - Each control carries an explicit accessible name ("Next page", "Go to page", "Page 4"), so the purpose is heard, not inferred from a glyph.
- Announce page changes, the clamp, and async transitions through a live region rather than relying on a visual change a screen-reader user cannot see.
- Keep the live region present in the DOM and toggle its content, rather than adding and removing it, so assistive tech has something stable to observe.
- All accessible-name and announcement strings are English by default and fully overridable, so the control localises with the rest of the app.
Visual
- Never encode the current page, a boundary, or a busy state in colour alone; pair it with a shape, weight, or text.
- Keep the focus ring visible and high-contrast, and readable at 200% zoom.
- Keep touch targets comfortable for coarse pointers; a cramped page row is hard to hit on mobile, where the control should bump its hit-targets automatically.
Async and loading
A paginator backed by a remote, paged API has to handle the gap between a click and the data
arriving. Bind the async state and the paginator becomes a consumer of it: while busy it gates
navigation, shows an indeterminate progress bar, flips aria-busy, and announces the
transition. The point is that a slow page never leaves the control in an ambiguous state and
never lets a second click queue behind the first.
Skins and density
The skin (numbered, minimal, pill, segmented, rail, dots, bar) changes only the
CSS layer via a host attribute; structure, ARIA, and keyboard behaviour are identical across
every skin, so the choice is purely cosmetic and never an accessibility decision. Density and
the responsive collapse are likewise device- and context-driven, not separate behaviours.
Mobile and responsive
- Keep the control stable across breakpoints; the active page must never get hidden by a layout rearrangement.
- Let a long page row truncate into the ellipsis menu rather than wrapping into an unreadable grid of numbers.
- On coarse pointers, prefer the compact skins and let the hit-targets grow; do not ship a desktop-dense row to a phone.
Quick checklist
- Keyboard-only: can you reach the page row, move the active page with the arrow keys, and Tab on to the next control?
- One screen-reader pass: is the landmark named, the current page announced with its position, and are the boundary, loading, and clamp states announced?
- Is the current page (and any boundary or busy state) communicated without relying on colour?
- Does a long row truncate into a reachable ellipsis menu instead of overflowing?
- Does resetting the page size return to the first page?
- Is pagination really the right pattern here, or does the user actually want load-more, infinite scroll (forward-only), or a stepper (an ordered task)?
Structural CSS shared by every paginator skin.
Layout-only - no colors, sizes, or borders beyond what is controlled via
--cngx-paginator-* custom properties. CngxPaginator (in @cngx/ui/paginator) references this file via
styleUrls; ng-packagr inlines it at publish time, so consumers of
@cngx/ui/paginator do not need @cngx/common/data at runtime. Selectors
are class-prefixed (.cngx-paginator__*) and apply globally under the
component's encapsulation: None.
State modifiers (driven by ARIA the organism sets)
[aria-current="page"]- the active page button[aria-disabled="true"]- dimmed,cursor: not-allowed(bound nav buttons / busy):focus-visible- focus ring via--cngx-paginator-focus-ring
Size preset - [data-paginator-size="compact" | "comfortable"] (the
private [density] input) shifts the button hit-target + font; the gap
tokens derive from the global --cngx-space-* scale, so a root
[data-density] compacts the paginator with every other component. The
default size needs no rule.
Slots
.cngx-paginator- the flex row host.cngx-paginator__segment- one projected segment wrapper.cngx-paginator__pages- the roving page-number row.cngx-paginator__button- any clickable cell (nav, page, ellipsis).cngx-paginator__page/__nav/__more- the three button kinds
Thematic tokens + the numbered, pill, segmented, minimal, rail,
and bar skins for CngxPaginator. Structural layout
lives in paginator-base.css (@cngx/common/data); this file only paints.
Every token is registered with inherits: true so a value set on the host
(skin block, density, consumer override) cascades to the descendant buttons
and the popover rows - inherits: false would strand the descendant on the
initial value. The Popover group themes the panel shared by the
items-per-page / page-of-pages listbox segments and the ellipsis overflow
menu.
Cross-skin layers (orthogonal to [data-skin]): hover / press scale + focus
halo + a busy-driven page-row fade (all reduced-motion-gated), a coarse-
pointer hit-target bump, a @container collapse of the first/last
conveniences on narrow widths, and an RTL chevron mirror. Density
(compact / comfortable) is owned by paginator-base.css.
Index#
Layout
Surface
State / Active
State / Disabled
Skin
Motion
State / Busy
Popover
Layout
<length>0.75remGap between adjacent segment rows on the host. Split from
--cngx-paginator-gap (the inter-page gap) so the projected segments breathe
while the page numerals stay tight.
<length>0.875remHost font size. Cascades to every numeral and label; density shifts it.
The font stack stays an unregistered custom property - no @property
syntax token describes a font-family list.
<length>2.25remHit-target size (min-width and height) of every paginator button.
Surface
*color-mix(in oklch, currentColor 10%, transparent)Hover background of a button.
<color>transparentBorder colour of a resting button.
State / Active
<color>oklch(0.66 0.19 50)Background of the active page button. Delegates to --cngx-color-primary.
See: [[--cngx-color-primary]]
<color>oklch(1 0 0)Text colour of the active page button (reads against the primary fill).
<color>transparentBorder colour of the active page button.
State / Focus
*0 0 0 4px color-mix(in oklch, currentColor 18%, transparent)Soft halo painted behind the focus-visible outline.
State / Disabled
<number>0.45Opacity multiplier applied to a disabled (bound or busy) button.
Skin
<color>oklch(0.96 0 0)Muted track the page row sits in under the segmented skin. Derived from
surface + border so it tracks the foundation light / dark scheme.
*0 1px 2px oklch(0 0 0 / 0.06)Drop shadow that raises the active page off the track under the
segmented skin.
<length>2pxThickness of the numbered skin's active-page underline bar.
<color>oklch(0.88 0.005 250)Colour of the hairline rail the page row rode under the old rail skin.
Superseded by the composed-progress rail. The rail skin now
renders cngx-pgn-rail (a CngxProgress track + fill plus an overlaid
knob), so this token has no reader; theme the track/fill through the
CngxProgress bridge and the knob via --cngx-paginator-rail-knob-*. Kept
registered for back-compat; slated for removal at the next breaking bump.
<length>2pxThickness of the old rail skin's underline + position marker.
Superseded by the composed-progress rail (see
[[--cngx-paginator-rail-color]]); the rail height is the CngxProgress
track height (--cngx-paginator-rail-height). No reader; kept for
back-compat, slated for removal at the next breaking bump.
<length>0.875remDiameter of the rail skin's position knob (the dot riding the fill edge).
<color>oklch(1 0 0)Fill of the rail skin's position knob. Defaults to the foundation surface.
See: [[--cngx-color-surface]]
<color>oklch(0.66 0.19 50)Ring colour of the rail skin's position knob. Defaults to the primary.
See: [[--cngx-color-primary]]
<color>oklch(0.88 0.005 250)Border + divider colour of the enclosed bar skin.
<length>0.375remCorner radius of the enclosed bar skin's outer box.
<length>0.5remDiameter of a resting (full-size) dot in the dots indicator.
<color>oklch(0.66 0.19 50)Fill of the active dot. Delegates to --cngx-color-primary.
See: [[--cngx-color-primary]]
Motion
<number>1Scale a button grows to on hover. Defaults to 1 (no grow) - the resting
hover is the background tint; opt into a grow by raising this. Flattened to
1 under reduced motion.
<number>1Scale a button grows to while pressed. Defaults to 1 (no pop); opt in by
raising this. Flattened to 1 under reduced motion.
<time>200msDuration of the hover / press / focus transitions.
<time>250msDuration of the page-row crossfade while the host reports busy.
State / Busy
<number>0.6Opacity the page row fades to while the host reports busy.
Popover
*CanvasSurface of the popover panel shared by the items-per-page / page-of-pages listbox segments and the ellipsis overflow menu.
*color-mix(in oklch, currentColor 20%, transparent)Border colour of the popover panel.
*0 4px 12px rgb(0 0 0 / 0.15)Drop shadow lifting the popover panel off the page.
<length>2remMinimum height of a popover row (page-size option / hidden-page item).
*color-mix(in oklch, currentColor 10%, transparent)Background of a hovered / keyboard-highlighted popover row. Shared by the listbox segments and the ellipsis overflow menu.
*color-mix(in oklch, var(--cngx-color-primary, oklch(0.66 0.19 50)) 14%, transparent)Background of the selected page-size row in the items-per-page panel.
*var(--cngx-color-primary, oklch(0.66 0.19 50))Text colour of the selected page-size row (reads as accent against the tint).
<integer>5Column count of the ellipsis overflow grid panel.
<length>2remCell size (column width + row min-height) of the ellipsis overflow grid.
Material theme coverage across all skins
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
inject,
signal,
} from '@angular/core';
import {
CngxPaginator,
CngxPaginatorDots,
CngxPaginatorFirst,
CngxPaginatorLast,
CngxPaginatorNext,
CngxPaginatorPageSize,
CngxPaginatorPages,
CngxPaginatorPrev,
CngxPaginatorRail,
CngxPaginatorRange,
CngxPaginatorStatus,
} from '@cngx/ui/paginator';
/**
* Every `CngxPaginator` skin rendered against a Material 3 palette, all seven
* driven by one shared page state.
*
* `skin` is paint-only: the seven paginators differ by a single `[data-skin]`
* attribute, yet they share the SAME `pageIndex` / `pageSize` signals - so
* paging any one row (clicking a page number, the dots, prev / next, or the
* bar's items-per-page) moves every other row in lockstep. The two halves are
* wired explicitly (`[pageIndex]` + `(pageIndexChange)`) rather than via the
* `[(pageIndex)]` sugar: the input is aliased onto the `CngxPaginate` host
* directive while the matching output lives on the `CngxPaginator` shell, and
* `strictTemplates` rejects a two-way box whose halves resolve to different
* targets (NG8007). The colour comes
* entirely from the published bridge: the example's stylesheet builds a real M3
* theme with `mat.theme` (which emits the `--mat-sys-*` system tokens), then
* `@cngx/themes/material/paginator-theme` maps every `--cngx-*` paginator knob
* onto its Material counterpart:
*
* ```scss
* @use '@angular/material' as mat;
* @use '@cngx/themes/material/paginator-theme.scss' as paginator;
*
* $theme: mat.define-theme((color: (theme-type: light, primary: mat.$azure-palette)));
* html {
* @include mat.theme($theme);
* @include paginator.theme($theme);
* }
* ```
*
* No per-skin colour overrides and no hand-copied tokens - each skin inherits
* primary / surface / outline from the palette. `ViewEncapsulation.None` lets
* the global `html` theme and the `:where(cngx-paginator)` bridge rules reach
* every paginator (including the items-per-page / ellipsis-overflow popovers,
* which the bridge paints as a mat-menu surface).
*
* Roboto is loaded at runtime: `mat.theme((typography: Roboto))` only NAMES the
* family on `--mat-sys-*-font`, it never ships the webfont, and the playground
* scaffold's `index.html` has no seam to add a `<link>` (Material auto-detect
* only fires for `<mat-*>` templates, and this one is cngx-only). So the
* constructor appends the Google Fonts `<link>` to `<head>` and tags `<body>`
* with the `mat-typography` / `mat-app-background` classes - without the font
* load every numeral falls back to the system sans and nothing reads as Material.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [
CngxPaginator,
CngxPaginatorFirst,
CngxPaginatorPrev,
CngxPaginatorPages,
CngxPaginatorNext,
CngxPaginatorLast,
CngxPaginatorRange,
CngxPaginatorStatus,
CngxPaginatorRail,
CngxPaginatorDots,
CngxPaginatorPageSize,
],
styleUrl: './skins-coverage.component.scss',
template: `
<div class="coverage">
<section>
<h3>numbered</h3>
<cngx-paginator
skin="numbered"
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-first />
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-next />
<cngx-pgn-last />
<cngx-pgn-range />
</cngx-paginator>
</section>
<section>
<h3>minimal</h3>
<cngx-paginator
skin="minimal"
data-nav-labels
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-prev />
<cngx-pgn-status />
<cngx-pgn-next />
</cngx-paginator>
</section>
<section>
<h3>pill</h3>
<cngx-paginator
skin="pill"
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-first />
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-next />
<cngx-pgn-last />
</cngx-paginator>
</section>
<section>
<h3>segmented</h3>
<cngx-paginator
skin="segmented"
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-next />
</cngx-paginator>
</section>
<section>
<h3>rail</h3>
<cngx-paginator
skin="rail"
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-prev />
<cngx-pgn-rail />
<cngx-pgn-status />
<cngx-pgn-next />
</cngx-paginator>
</section>
<section>
<h3>dots</h3>
<cngx-paginator
skin="dots"
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-prev />
<cngx-pgn-dots />
<cngx-pgn-next />
</cngx-paginator>
</section>
<section>
<h3>bar</h3>
<cngx-paginator
skin="bar"
[total]="total"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-page-size [options]="sizes" />
<cngx-pgn-range />
<cngx-pgn-first />
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-next />
<cngx-pgn-last />
</cngx-paginator>
</section>
<div class="coverage__toolbar">
<span>Shared page: {{ pageIndex() + 1 }} of {{ pageCount() }}</span>
<span>Page size: {{ pageSize() }}</span>
</div>
</div>
`,
})
export class SkinsCoverageExample {
protected readonly total = 240;
protected readonly sizes = [10, 25, 50] as const;
protected readonly pageIndex = signal(2);
protected readonly pageSize = signal(10);
protected readonly pageCount = computed(() => Math.max(1, Math.ceil(this.total / this.pageSize())));
private readonly document = inject(DOCUMENT);
constructor() {
const head = this.document.head;
const preconnect = this.document.createElement('link');
preconnect.rel = 'preconnect';
preconnect.href = 'https://fonts.gstatic.com';
preconnect.crossOrigin = '';
head.appendChild(preconnect);
const roboto = this.document.createElement('link');
roboto.rel = 'stylesheet';
roboto.href = 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap';
head.appendChild(roboto);
this.document.body.classList.add('mat-typography', 'mat-app-background');
}
}
export { SkinsCoverageExample as AppComponent };
Data-table footer (Material)
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
inject,
signal,
} from '@angular/core';
import {
CngxPaginator,
CngxPaginatorFirst,
CngxPaginatorLast,
CngxPaginatorNext,
CngxPaginatorPageSize,
CngxPaginatorPages,
CngxPaginatorPrev,
CngxPaginatorRange,
} from '@cngx/ui/paginator';
interface Row {
readonly id: number;
readonly name: string;
readonly team: string;
readonly commits: number;
}
const TEAMS = ['Platform', 'Payments', 'Growth', 'Data', 'Mobile'] as const;
const NAMES = [
'Ada Lovelace', 'Alan Turing', 'Grace Hopper', 'Edsger Dijkstra', 'Donald Knuth',
'Barbara Liskov', 'Ken Thompson', 'Margaret Hamilton', 'Linus Torvalds', 'Radia Perlman',
];
/**
* A data table whose footer is a `cngx-paginator` in the `bar` skin, Material 3
* themed - the canonical `mat-paginator` parity layout. The page slice is a pure
* `computed()` of `pageIndex` / `pageSize`; the table re-renders as the shared
* signals change. Changing the items-per-page resets to the first page (brain
* semantics) and the range readout recomputes.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [
CngxPaginator,
CngxPaginatorPageSize,
CngxPaginatorRange,
CngxPaginatorFirst,
CngxPaginatorPrev,
CngxPaginatorPages,
CngxPaginatorNext,
CngxPaginatorLast,
],
styleUrl: './data-table.component.scss',
template: `
<div class="table-card">
<table class="grid">
<thead>
<tr>
<th class="num">#</th>
<th>Name</th>
<th>Team</th>
<th class="num">Commits</th>
</tr>
</thead>
<tbody>
@for (row of pageRows(); track row.id) {
<tr>
<td class="num">{{ row.id }}</td>
<td>{{ row.name }}</td>
<td>{{ row.team }}</td>
<td class="num">{{ row.commits }}</td>
</tr>
}
</tbody>
</table>
<cngx-paginator
class="grid-footer"
skin="bar"
[total]="rows.length"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<span class="grid-footer__label" aria-hidden="true">Rows per page:</span>
<cngx-pgn-page-size [options]="sizes" />
<cngx-pgn-range />
<cngx-pgn-first />
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-next />
<cngx-pgn-last />
</cngx-paginator>
</div>
`,
})
export class DataTableExample {
protected readonly sizes = [5, 10, 25] as const;
protected readonly pageIndex = signal(0);
protected readonly pageSize = signal(5);
protected readonly rows: readonly Row[] = Array.from({ length: 47 }, (_, i) => ({
id: i + 1,
name: NAMES[i % NAMES.length],
team: TEAMS[i % TEAMS.length],
commits: ((i * 37) % 240) + 3,
}));
protected readonly pageRows = computed<readonly Row[]>(() => {
const start = this.pageIndex() * this.pageSize();
return this.rows.slice(start, start + this.pageSize());
});
private readonly document = inject(DOCUMENT);
constructor() {
const link = this.document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap';
this.document.head.appendChild(link);
this.document.body.classList.add('mat-typography', 'mat-app-background');
}
}
export { DataTableExample as AppComponent };
Live filter with reset-to-first-page
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import {
CngxPaginator,
CngxPaginatorFirst,
CngxPaginatorLast,
CngxPaginatorNext,
CngxPaginatorPages,
CngxPaginatorPrev,
CngxPaginatorRange,
} from '@cngx/ui/paginator';
const FRUITS = [
'Apricot', 'Apple', 'Avocado', 'Banana', 'Blackberry', 'Blueberry', 'Cherry', 'Clementine',
'Cranberry', 'Currant', 'Date', 'Dragonfruit', 'Elderberry', 'Fig', 'Gooseberry', 'Grape',
'Grapefruit', 'Guava', 'Honeydew', 'Jackfruit', 'Kiwi', 'Kumquat', 'Lemon', 'Lime', 'Lychee',
'Mango', 'Melon', 'Mulberry', 'Nectarine', 'Orange', 'Papaya', 'Passionfruit', 'Peach', 'Pear',
'Persimmon', 'Pineapple', 'Plum', 'Pomegranate', 'Quince', 'Raspberry', 'Redcurrant',
'Strawberry', 'Tangerine', 'Watermelon',
];
/**
* A live-filtered list paged by `cngx-paginator`. The `[resetOn]` input is bound
* to the filter term: whenever it changes the paginator jumps back to the first
* page, so a narrowed result never strands the user on a now-empty page. Paging
* is controlled - `pageIndex` is owned here and fed back through
* `(pageIndexChange)`; `[total]` tracks the filtered count so the page math and
* the range readout stay correct as the list shrinks.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CngxPaginator,
CngxPaginatorFirst,
CngxPaginatorPrev,
CngxPaginatorPages,
CngxPaginatorNext,
CngxPaginatorLast,
CngxPaginatorRange,
],
styleUrl: './filter-reset.component.scss',
template: `
<div class="panel">
<label class="field">
<span class="field__label">Filter fruit</span>
<input
type="text"
class="field__input"
[value]="filter()"
(input)="filter.set($any($event.target).value)"
placeholder="Type to narrow the list"
/>
</label>
<p class="status">
<strong>{{ filtered().length }}</strong> match(es) - page
<strong>{{ pageIndex() + 1 }}</strong> of {{ pageCount() }}
</p>
@if (pageItems().length) {
<ul class="list">
@for (fruit of pageItems(); track fruit) {
<li class="list__item">{{ fruit }}</li>
}
</ul>
} @else {
<p class="empty">No fruit matches "{{ filter() }}".</p>
}
<cngx-paginator
skin="numbered"
[total]="filtered().length"
[resetOn]="filter()"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize"
>
<cngx-pgn-first />
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-next />
<cngx-pgn-last />
<cngx-pgn-range />
</cngx-paginator>
</div>
`,
})
export class FilterResetExample {
protected readonly pageSize = 6;
protected readonly pageIndex = signal(0);
protected readonly filter = signal('');
protected readonly filtered = computed<readonly string[]>(() => {
const term = this.filter().trim().toLowerCase();
return term ? FRUITS.filter((f) => f.toLowerCase().includes(term)) : FRUITS;
});
protected readonly pageItems = computed<readonly string[]>(() => {
const start = this.pageIndex() * this.pageSize;
return this.filtered().slice(start, start + this.pageSize);
});
protected readonly pageCount = computed(() =>
Math.max(1, Math.ceil(this.filtered().length / this.pageSize)),
);
}
export { FilterResetExample as AppComponent };
Card grid with go-to and responsive collapse
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import {
CngxPaginator,
CngxPaginatorGoto,
CngxPaginatorNext,
CngxPaginatorPageSize,
CngxPaginatorPages,
CngxPaginatorPrev,
CngxPaginatorRange,
CngxPaginatorStatus,
} from '@cngx/ui/paginator';
interface Product {
readonly id: number;
readonly name: string;
readonly price: number;
}
const ADJ = ['Matte', 'Glossy', 'Rugged', 'Slim', 'Pro', 'Mini', 'Max', 'Eco'];
const NOUN = ['Keyboard', 'Mouse', 'Monitor', 'Webcam', 'Dock', 'Stand', 'Hub', 'Lamp'];
/**
* A paged card grid with a composed paginator: items-per-page, a "go to page"
* number input, a range readout, and a responsive collapse. `[responsive]`
* makes a `@container` query swap the projected page-number row for a compact
* "Page n of m" status once the paginator's own container narrows - compose both
* `cngx-pgn-pages` and `cngx-pgn-status` and the right one shows per width. The
* visible slice is a pure `computed()` of the shared page signals.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CngxPaginator,
CngxPaginatorPageSize,
CngxPaginatorGoto,
CngxPaginatorPrev,
CngxPaginatorPages,
CngxPaginatorStatus,
CngxPaginatorNext,
CngxPaginatorRange,
],
styleUrl: './card-grid.component.scss',
template: `
<div class="wrap">
<div class="cards">
@for (p of pageItems(); track p.id) {
<article class="card">
<div class="card__thumb" aria-hidden="true">{{ p.name.charAt(0) }}</div>
<h3 class="card__name">{{ p.name }}</h3>
<p class="card__price">{{ '$' + p.price }}</p>
</article>
}
</div>
<cngx-paginator
class="bar"
skin="bar"
[responsive]="true"
[total]="products.length"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-page-size [options]="sizes" />
<cngx-pgn-range />
<cngx-pgn-prev />
<cngx-pgn-pages />
<cngx-pgn-status />
<cngx-pgn-next />
<cngx-pgn-goto />
</cngx-paginator>
</div>
`,
})
export class CardGridExample {
protected readonly sizes = [6, 9, 12] as const;
protected readonly pageIndex = signal(0);
protected readonly pageSize = signal(6);
protected readonly products: readonly Product[] = Array.from({ length: 33 }, (_, i) => ({
id: i + 1,
name: `${ADJ[i % ADJ.length]} ${NOUN[(i * 3) % NOUN.length]}`,
price: ((i * 17) % 180) + 19,
}));
protected readonly pageItems = computed<readonly Product[]>(() => {
const start = this.pageIndex() * this.pageSize();
return this.products.slice(start, start + this.pageSize());
});
}
export { CardGridExample as AppComponent };
Alphabetical bucket paging
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
signal,
} from '@angular/core';
import { CngxBucketPaginate, type CngxBucket } from '@cngx/common/data';
import { CngxPaginatorAlpha } from '@cngx/ui/paginator';
interface Person {
readonly name: string;
readonly role: string;
readonly location: string;
}
const PEOPLE: readonly Person[] = [
{ name: 'Ada Lovelace', role: 'Analyst', location: 'London' },
{ name: 'Brendan Eich', role: 'Architect', location: 'Mountain View' },
{ name: 'Carmen Diaz', role: 'Designer', location: 'Madrid' },
{ name: 'Dennis Ritchie', role: 'Engineer', location: 'Murray Hill' },
{ name: 'Edsger Dijkstra', role: 'Researcher', location: 'Eindhoven' },
{ name: 'Frances Allen', role: 'Compiler Lead', location: 'Peekskill' },
{ name: 'Grace Hopper', role: 'Admiral', location: 'Arlington' },
{ name: 'Hedy Lamarr', role: 'Inventor', location: 'Vienna' },
{ name: 'Ivan Sutherland', role: 'Pioneer', location: 'Portland' },
{ name: 'Joan Clarke', role: 'Cryptanalyst', location: 'Bletchley' },
{ name: 'Ken Thompson', role: 'Engineer', location: 'Berkeley' },
{ name: 'Linus Torvalds', role: 'Maintainer', location: 'Portland' },
{ name: 'Margaret Hamilton', role: 'Director', location: 'Cambridge' },
{ name: 'Radia Perlman', role: 'Engineer', location: 'Seattle' },
{ name: 'Tim Berners-Lee', role: 'Director', location: 'Geneva' },
{ name: 'Vint Cerf', role: 'Evangelist', location: 'Reston' },
];
/**
* Alphabetical bucket paging. `cngxBucketPaginate` partitions the list into
* letter-range buckets (A-C, D-F ...) and `cngx-pgn-alpha` renders the chip
* strip - a sibling model to the page-index brain, not a flag on it. Selecting
* a chip filters the list; re-selecting it clears the filter. Empty buckets are
* disabled with a stated reason, never silently dead.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [CngxBucketPaginate, CngxPaginatorAlpha],
styleUrl: './alpha-mode.component.scss',
template: `
<div
class="demo-card demo-pgn-alpha-stack"
cngxBucketPaginate
[buckets]="buckets"
[items]="people()"
[active]="active()"
(activeChange)="active.set($event)"
>
<cngx-pgn-alpha />
<ul class="demo-list-flush">
@for (p of visible(); track p.name) {
<li class="demo-list-row"><strong>{{ p.name }}</strong> - {{ p.role }}, {{ p.location }}</li>
}
</ul>
</div>
`,
})
export class AlphaModeExample {
protected readonly people = signal<readonly Person[]>(PEOPLE);
protected readonly buckets: readonly CngxBucket<Person>[] = [
{ label: 'A-C', match: (p: Person) => this.inRange(p, 'A', 'C') },
{ label: 'D-F', match: (p: Person) => this.inRange(p, 'D', 'F') },
{ label: 'G-I', match: (p: Person) => this.inRange(p, 'G', 'I') },
{ label: 'J-L', match: (p: Person) => this.inRange(p, 'J', 'L') },
{ label: 'M-O', match: (p: Person) => this.inRange(p, 'M', 'O') },
{ label: 'P-R', match: (p: Person) => this.inRange(p, 'P', 'R') },
{ label: 'S-U', match: (p: Person) => this.inRange(p, 'S', 'U') },
{ label: 'V-Z', match: (p: Person) => this.inRange(p, 'V', 'Z') },
];
protected readonly active = signal<string | null>(null);
protected readonly visible = computed<readonly Person[]>(() => {
const label = this.active();
if (label === null) {
return this.people();
}
const bucket = this.buckets.find((b) => b.label === label);
return bucket ? this.people().filter((p) => bucket.match(p)) : this.people();
});
private inRange(p: Person, lo: string, hi: string): boolean {
const c = p.name[0]?.toUpperCase() ?? '';
return c >= lo && c <= hi;
}
}
export { AlphaModeExample as AppComponent };
Load-more (append) paging
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
signal,
} from '@angular/core';
import { CngxPaginator, CngxPaginatorLoadMore } from '@cngx/ui/paginator';
interface Person {
readonly name: string;
readonly role: string;
readonly location: string;
}
const PEOPLE: readonly Person[] = [
{ name: 'Ada Lovelace', role: 'Analyst', location: 'London' },
{ name: 'Brendan Eich', role: 'Architect', location: 'Mountain View' },
{ name: 'Carmen Diaz', role: 'Designer', location: 'Madrid' },
{ name: 'Dennis Ritchie', role: 'Engineer', location: 'Murray Hill' },
{ name: 'Edsger Dijkstra', role: 'Researcher', location: 'Eindhoven' },
{ name: 'Frances Allen', role: 'Compiler Lead', location: 'Peekskill' },
{ name: 'Grace Hopper', role: 'Admiral', location: 'Arlington' },
{ name: 'Hedy Lamarr', role: 'Inventor', location: 'Vienna' },
{ name: 'Ivan Sutherland', role: 'Pioneer', location: 'Portland' },
{ name: 'Joan Clarke', role: 'Cryptanalyst', location: 'Bletchley' },
{ name: 'Ken Thompson', role: 'Engineer', location: 'Berkeley' },
{ name: 'Linus Torvalds', role: 'Maintainer', location: 'Portland' },
{ name: 'Margaret Hamilton', role: 'Director', location: 'Cambridge' },
{ name: 'Radia Perlman', role: 'Engineer', location: 'Seattle' },
{ name: 'Tim Berners-Lee', role: 'Director', location: 'Geneva' },
{ name: 'Vint Cerf', role: 'Evangelist', location: 'Reston' },
];
/**
* Load-more (append) paging over the same brain. `cngx-pgn-load-more` steps
* `pageIndex` forward; the host slices from the top through the current page,
* so each click reveals the next batch instead of replacing the page. On the
* last page the button disables and switches to the all-loaded label.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [CngxPaginator, CngxPaginatorLoadMore],
styleUrl: './load-more-mode.component.scss',
template: `
<div class="demo-card">
<ul class="demo-list-flush">
@for (p of shownItems(); track p.name) {
<li class="demo-list-row"><strong>{{ p.name }}</strong> - {{ p.role }}, {{ p.location }}</li>
}
</ul>
<cngx-paginator
aria-label="People"
[total]="people().length"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-load-more />
</cngx-paginator>
</div>
`,
})
export class LoadMoreModeExample {
protected readonly people = signal<Person[]>([
...PEOPLE,
...PEOPLE.map((p: Person) => ({ ...p, name: p.name + ' Jr.' })),
...PEOPLE.map((p: Person) => ({ ...p, name: p.name + ' III' })),
]);
protected readonly pageIndex = signal(0);
protected readonly pageSize = signal(5);
// Append-style: slice from the top through the current page.
protected readonly shownItems = computed<Person[]>(() =>
this.people().slice(0, (this.pageIndex() + 1) * this.pageSize()),
);
}
export { LoadMoreModeExample as AppComponent };
Infinite scroll paging
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
computed,
effect,
signal,
untracked,
} from '@angular/core';
import { createManualState } from '@cngx/common/data';
import { CngxPaginator, CngxPaginatorInfinite } from '@cngx/ui/paginator';
interface Person {
readonly name: string;
readonly role: string;
readonly location: string;
}
const PEOPLE: readonly Person[] = [
{ name: 'Ada Lovelace', role: 'Analyst', location: 'London' },
{ name: 'Brendan Eich', role: 'Architect', location: 'Mountain View' },
{ name: 'Carmen Diaz', role: 'Designer', location: 'Madrid' },
{ name: 'Dennis Ritchie', role: 'Engineer', location: 'Murray Hill' },
{ name: 'Edsger Dijkstra', role: 'Researcher', location: 'Eindhoven' },
{ name: 'Frances Allen', role: 'Compiler Lead', location: 'Peekskill' },
{ name: 'Grace Hopper', role: 'Admiral', location: 'Arlington' },
{ name: 'Hedy Lamarr', role: 'Inventor', location: 'Vienna' },
{ name: 'Ivan Sutherland', role: 'Pioneer', location: 'Portland' },
{ name: 'Joan Clarke', role: 'Cryptanalyst', location: 'Bletchley' },
{ name: 'Ken Thompson', role: 'Engineer', location: 'Berkeley' },
{ name: 'Linus Torvalds', role: 'Maintainer', location: 'Portland' },
{ name: 'Margaret Hamilton', role: 'Director', location: 'Cambridge' },
{ name: 'Radia Perlman', role: 'Engineer', location: 'Seattle' },
{ name: 'Tim Berners-Lee', role: 'Director', location: 'Geneva' },
{ name: 'Vint Cerf', role: 'Evangelist', location: 'Reston' },
];
/**
* Infinite scroll paging over the same brain. `cngx-pgn-infinite` drops a
* sentinel that auto-advances `pageIndex` as it enters the scroll container, so
* the list grows as you scroll - while each batch keeps its addressable page
* boundary (the sticky "Page n" divider). Binding `[state]` makes the busy-gate
* pace the auto-load so the sentinel waits instead of racing through pages.
*/
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [CngxPaginator, CngxPaginatorInfinite],
styleUrl: './infinite-mode.component.scss',
template: `
<div class="demo-card">
<div class="demo-pgn-infinite-frame">
@for (group of revealed(); track group.page) {
<div class="demo-page-divider">Page {{ group.page }}</div>
<ul class="demo-list-flush">
@for (p of group.items; track p.name) {
<li class="demo-list-row"><strong>{{ p.name }}</strong> - {{ p.role }}, {{ p.location }}</li>
}
</ul>
}
<cngx-paginator
aria-label="People"
[total]="people().length"
[state]="loadState"
[pageIndex]="pageIndex()"
(pageIndexChange)="pageIndex.set($event)"
[pageSize]="pageSize()"
(pageSizeChange)="pageSize.set($event)"
>
<cngx-pgn-infinite root=".demo-pgn-infinite-frame" rootMargin="0px 0px 48px 0px" />
</cngx-paginator>
</div>
</div>
`,
})
export class InfiniteModeExample {
protected readonly people = signal<Person[]>(
Array.from({ length: 6 }, (_, copy: number) =>
PEOPLE.map((p: Person) => (copy === 0 ? p : { ...p, name: p.name + ' #' + (copy + 1) })),
).flat(),
);
protected readonly pageSize = signal(6);
protected readonly pageIndex = signal(0);
// Simulated fetch state: each advance flips busy for a beat, so the sentinel's
// busy-gate paces the auto-load instead of racing through every page at once.
protected readonly loadState = createManualState<void>();
// Reveal a page only once its simulated fetch has settled - the row appearing
// on settle is what re-arms the observer for the next scroll.
protected readonly loadedPages = signal(1);
protected readonly revealed = computed<{ page: number; items: Person[] }[]>(() => {
const size = this.pageSize();
const groups: { page: number; items: Person[] }[] = [];
for (let page = 0; page < this.loadedPages(); page++) {
const items = this.people().slice(page * size, (page + 1) * size);
if (items.length) {
groups.push({ page: page + 1, items });
}
}
return groups;
});
constructor() {
// Pace the auto-load: each advance flips busy for a beat so the sentinel
// waits on the busy-gate instead of racing through every page at once.
effect(() => {
const wanted = this.pageIndex() + 1;
untracked(() => {
if (wanted <= this.loadedPages()) {
return;
}
this.loadState.set('loading');
setTimeout(() => {
this.loadedPages.set(wanted);
this.loadState.set('success');
}, 500);
});
});
}
}
export { InfiniteModeExample as AppComponent };