Pagination
Navigates between pages of results.
- v1.0.0
- SSR
- Zoneless
- 1.4 kB gzip
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/ui/pagination/pagination';
/** Links to pages, with the current one marked. */
@Component({
selector: 'example-pagination-demo',
imports: [
NdIcon,
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
],
template: `
<nav ndPagination>
<ul ndPaginationContent>
<li ndPaginationItem>
<a ndPaginationPrevious href="#page-1">
<svg ndIcon="chevron-left"></svg>
<span class="hidden sm:block">Previous</span>
</a>
</li>
<li ndPaginationItem><a ndPaginationLink href="#page-1">1</a></li>
<li ndPaginationItem>
<a ndPaginationLink href="#page-2" active>2</a>
</li>
<li ndPaginationItem><a ndPaginationLink href="#page-3">3</a></li>
<li ndPaginationItem>
<span ndPaginationEllipsis><svg ndIcon="ellipsis"></svg></span>
</li>
<li ndPaginationItem>
<a ndPaginationNext href="#page-3">
<span class="hidden sm:block">Next</span>
<svg ndIcon="chevron-right"></svg>
</a>
</li>
</ul>
</nav>
`,
})
export class PaginationDemoExample {}
Pagination moves between pages of results. It's a nav landmark named
"Pagination" with a list of links, styled as buttons: ghost for other
pages, outlined for the current one, which is marked
aria-current="page". The previous and next links are named for screen
readers, and their chevrons turn around in right-to-left layouts.
Installation
npx @ndop/cli add paginationpnpm dlx @ndop/cli add paginationyarn dlx @ndop/cli add paginationbunx @ndop/cli add paginationInstall the dependencies
npm install @ndop/core @ndop/primitivespnpm add @ndop/core @ndop/primitivesyarn add @ndop/core @ndop/primitivesbun add @ndop/core @ndop/primitivesCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/pagination/pagination.tsimport { booleanAttribute, computed, Directive, input } from '@angular/core'; import { cn } from '@/lib/utils'; import { buttonVariants, type ButtonSize } from '@/ui/button/button'; /** * Page navigation for a list of results, as a `nav` landmark. * * @example * ```html * <nav ndPagination> * <ul ndPaginationContent> * <li ndPaginationItem> * <a ndPaginationPrevious href="?page=1"><svg ndIcon="chevron-left"></svg><span>Previous</span></a> * </li> * <li ndPaginationItem><a ndPaginationLink href="?page=2" active>2</a></li> * <li ndPaginationItem> * <a ndPaginationNext href="?page=3"><span>Next</span><svg ndIcon="chevron-right"></svg></a> * </li> * </ul> * </nav> * ``` */ @Directive({ selector: 'nav[ndPagination]', host: { 'data-slot': 'pagination', '[attr.aria-label]': 'label()', '[class]': 'classes()', }, }) export class Pagination { /** Name of the landmark. */ readonly label = input('Pagination'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('mx-auto flex w-full justify-center', this.class()), ); } /** * The list of a pagination's links. * * @example * ```html * <ul ndPaginationContent>…</ul> * ``` */ @Directive({ selector: 'ul[ndPaginationContent]', host: { 'data-slot': 'pagination-content', '[class]': 'classes()' }, }) export class PaginationContent { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex flex-row items-center gap-1', this.class()), ); } /** * One entry of a pagination: a link, or an ellipsis. * * @example * ```html * <li ndPaginationItem>…</li> * ``` */ @Directive({ selector: 'li[ndPaginationItem]', host: { 'data-slot': 'pagination-item' }, }) export class PaginationItem {} /** * A link to one page, styled as a ghost button, or an outline one for the * current page, which it marks `aria-current="page"`. On a `button`, it * calls your handler instead of navigating. * * @example * ```html * <a ndPaginationLink href="?page=2" active>2</a> * ``` */ @Directive({ selector: 'a[ndPaginationLink], button[ndPaginationLink]', host: { 'data-slot': 'pagination-link', '[attr.aria-current]': 'active() ? "page" : null', '[attr.data-active]': 'active()', '[class]': 'classes()', }, }) export class PaginationLink { /** Whether it's the current page. */ readonly active = input(false, { transform: booleanAttribute }); /** Button size. */ readonly size = input<ButtonSize>('icon'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.active() ? 'outline' : 'ghost', size: this.size(), }), this.class(), ), ); } /** * The link to the previous page. Put a `chevron-left` icon and the word * "Previous" in it; the icon turns around in right-to-left layouts. * * @example * ```html * <a ndPaginationPrevious href="?page=1"><svg ndIcon="chevron-left"></svg><span>Previous</span></a> * ``` */ @Directive({ selector: 'a[ndPaginationPrevious], button[ndPaginationPrevious]', host: { 'data-slot': 'pagination-link', 'aria-label': 'Go to previous page', '[class]': 'classes()', }, }) export class PaginationPrevious { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'default' }), 'gap-1 px-2.5 sm:ps-2.5', 'rtl:[&>svg]:rotate-180', this.class(), ), ); } /** * The link to the next page. Put the word "Next" and a `chevron-right` * icon in it; the icon turns around in right-to-left layouts. * * @example * ```html * <a ndPaginationNext href="?page=3"><span>Next</span><svg ndIcon="chevron-right"></svg></a> * ``` */ @Directive({ selector: 'a[ndPaginationNext], button[ndPaginationNext]', host: { 'data-slot': 'pagination-link', 'aria-label': 'Go to next page', '[class]': 'classes()', }, }) export class PaginationNext { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'default' }), 'gap-1 px-2.5 sm:pe-2.5', 'rtl:[&>svg]:rotate-180', this.class(), ), ); } /** * Stands for pages left out of a long range; hidden from assistive * technology. Put an `ellipsis` icon in it. * * @example * ```html * <span ndPaginationEllipsis><svg ndIcon="ellipsis"></svg></span> * ``` */ @Directive({ selector: '[ndPaginationEllipsis]', host: { 'data-slot': 'pagination-ellipsis', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class PaginationEllipsis { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'flex size-9 items-center justify-center', '[&>svg]:size-4', this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';src/app/ui/button/button.tsimport { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf<typeof buttonVariants, 'variant'>; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf<typeof buttonVariants, 'size'>; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * <button ndButton variant="outline" size="sm">Cancel</button> * <a ndButton variant="link" href="/docs">Docs</a> * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input<ButtonVariant>('default'); /** Height, padding and icon size. */ readonly size = input<ButtonSize>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); }Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/ui/pagination/pagination';<nav ndPagination>
<ul ndPaginationContent>
<li ndPaginationItem>
<a ndPaginationPrevious href="#page-1">
<svg ndIcon="chevron-left"></svg>
<span class="hidden sm:block">Previous</span>
</a>
</li>
<li ndPaginationItem><a ndPaginationLink href="#page-1">1</a></li>
<li ndPaginationItem>
<a ndPaginationLink href="#page-2" active>2</a>
</li>
<li ndPaginationItem><a ndPaginationLink href="#page-3">3</a></li>
<li ndPaginationItem>
<span ndPaginationEllipsis><svg ndIcon="ellipsis"></svg></span>
</li>
<li ndPaginationItem>
<a ndPaginationNext href="#page-3">
<span class="hidden sm:block">Next</span>
<svg ndIcon="chevron-right"></svg>
</a>
</li>
</ul>
</nav>Examples
Pagination state
Pages kept in component state, with buttons.
Showing results 1 to 10 of 50
import { Component, computed, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/ui/pagination/pagination';
/**
* Pages kept in component state: buttons instead of links, with the
* current page marked and the arrows disabled at either end.
*/
@Component({
selector: 'example-pagination-state',
imports: [
NdIcon,
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
],
template: `
<div class="grid gap-4">
<p class="text-center text-sm text-muted-foreground" aria-live="polite">
Showing results {{ (page() - 1) * 10 + 1 }} to {{ page() * 10 }} of 50
</p>
<nav ndPagination label="Results pages">
<ul ndPaginationContent>
<li ndPaginationItem>
<button
ndPaginationPrevious
[disabled]="page() === 1"
(click)="page.set(page() - 1)"
>
<svg ndIcon="chevron-left"></svg>
<span class="hidden sm:block">Previous</span>
</button>
</li>
@for (number of pages(); track number) {
<li ndPaginationItem>
<button
ndPaginationLink
[active]="number === page()"
(click)="page.set(number)"
>
{{ number }}
</button>
</li>
}
<li ndPaginationItem>
<button
ndPaginationNext
[disabled]="page() === count"
(click)="page.set(page() + 1)"
>
<span class="hidden sm:block">Next</span>
<svg ndIcon="chevron-right"></svg>
</button>
</li>
</ul>
</nav>
</div>
`,
})
export class PaginationStateExample {
protected readonly count = 5;
protected readonly page = signal(1);
protected readonly pages = computed(() =>
Array.from({ length: this.count }, (_, index) => index + 1),
);
}
Accessibility
Pagination has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on.
API reference
Pagination
nav[ndPagination]Page navigation for a list of results, as a nav landmark.
| Pagination binding | Type | Default |
|---|---|---|
[label]Name of the landmark. | string | 'Pagination' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | pagination |
PaginationContent
ul[ndPaginationContent]The list of a pagination's links.
| PaginationContent binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | pagination-content |
PaginationItem
li[ndPaginationItem]One entry of a pagination: a link, or an ellipsis.
| Data attribute | Values |
|---|---|
data-slot | pagination-item |
PaginationLink
a[ndPaginationLink], button[ndPaginationLink]A link to one page, styled as a ghost button, or an outline one for the
current page, which it marks aria-current="page". On a button, it
calls your handler instead of navigating.
| PaginationLink binding | Type | Default |
|---|---|---|
[active]Whether it's the current page. | boolean | false |
[size]Button size. | 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg' | 'icon' |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | pagination-link |
data-active | — |
PaginationPrevious
a[ndPaginationPrevious], button[ndPaginationPrevious]The link to the previous page. Put a chevron-left icon and the word
"Previous" in it; the icon turns around in right-to-left layouts.
| PaginationPrevious binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | pagination-link |
PaginationNext
a[ndPaginationNext], button[ndPaginationNext]The link to the next page. Put the word "Next" and a chevron-right
icon in it; the icon turns around in right-to-left layouts.
| PaginationNext binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | pagination-link |
PaginationEllipsis
[ndPaginationEllipsis]Stands for pages left out of a long range; hidden from assistive
technology. Put an ellipsis icon in it.
| PaginationEllipsis binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | pagination-ellipsis |
Agent notes
Do
- Use links with an
href(orrouterLink) when each page has a URL, so pages can be bookmarked and opened in a new tab. - Use
button[ndPaginationLink]when the page lives only in component state, and keepactivein sync. - Show the first, last and nearby pages, with
ndPaginationEllipsisfor the gaps.
Don't
- Don't paginate a short list that fits on one page.
- Don't leave out the previous and next links on long ranges.