Pagination for React
Numbers with gaps, simple previous and next, a results line and load more. Compact on phones.
One of 6 components in Navigation elements
Numbers or load more
Pagination moves through long lists in pieces. This component builds every page as a real link, so search engines can follow them, shows gaps where pages are skipped and keeps the first, the last and the neighbours of the current page in view.
Numbers is the classic for search results, blogs and tables, with previous and next and a line such as 21 to 40 of 312. On phones it shrinks to previous, the current page and next. Load more appends the next batch below the list and moves focus to the first new item, a good fit for product grids and feeds.
Use numbers when people come back to a position, such as page three of a catalog. Use load more when people browse. Keep the page size stable and tell people how many results there are, that helps them decide whether to refine the search.
Details
What Pagination brings along, read from the registry and the source.
- Category
- Navigation elements
- Area
- Elements
- Variants
- 2
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/pagination.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 15 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PaginationProps interface from components/ui/pagination.tsx, with the comments from the code.
pageoptionalnumberdefaultPageoptionalnumbertotalrequirednumberonPageChangeoptional(page: number) => voidhrefoptional(page: number) => stringBuild real links for each page, e.g. (p) => `?page=${p}`
variantoptional'numbers' | 'simple'numbers: buttons for each page. simple: previous and next with "Page 3 of 12".
summaryoptional{ from: number; to: number; count: number }Results line on the left, e.g. { from: 21, to: 40, count: 248 }
Built-in texts
Pagination writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 15 texts
| Key | English | German |
|---|---|---|
| label | English Pagination | German Seitennavigation |
| prev | English Previous | German Zurück |
| next | English Next | German Weiter |
| page | English Page | German Seite |
| of | English of | German von |
| first | English First page | German Erste Seite |
| last | English Last page | German Letzte Seite |
| showing | English Showing | German Zeige |
| to | English to | German bis |
| results | English results | German Ergebnissen |
| perPage | English Rows per page | German Zeilen pro Seite |
| more | English Load more | German Mehr laden |
| loading | English Loading | German Wird geladen |
| loaded | English shown | German angezeigt |
| all | English All shown | German Alles angezeigt |
Accessibility
Read from the source of the component.
- Sets aria-busy, aria-current, aria-label, aria-live, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
- Uses the role progressbar.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 298 lines
'use client';
import { useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
const TEXTS = {
en: { label: 'Pagination', prev: 'Previous', next: 'Next', page: 'Page', of: 'of', first: 'First page', last: 'Last page', showing: 'Showing', to: 'to', results: 'results', perPage: 'Rows per page', more: 'Load more', loading: 'Loading', loaded: 'shown', all: 'All shown' },
de: { label: 'Seitennavigation', prev: 'Zurück', next: 'Weiter', page: 'Seite', of: 'von', first: 'Erste Seite', last: 'Letzte Seite', showing: 'Zeige', to: 'bis', results: 'Ergebnissen', perPage: 'Zeilen pro Seite', more: 'Mehr laden', loading: 'Wird geladen', loaded: 'angezeigt', all: 'Alles angezeigt' },
};
type Texts = (typeof TEXTS)['en'];
/* Entrance when the bar scrolls into view, a number that rolls in when it changes, a pop for "all shown" */
const STYLES =
'.kitfx-pg-in .kitfx-pg-part{animation:kitfx-pg-in .55s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 40ms)}' +
'@keyframes kitfx-pg-in{from{opacity:0;transform:translateY(8px)}}' +
'.kitfx-pg-roll{display:inline-block;animation:kitfx-pg-roll .4s var(--ease-kit) both}@keyframes kitfx-pg-roll{from{opacity:0;transform:translateY(.45em)}}' +
'.kitfx-pg-pop{animation:kitfx-pg-pop .5s cubic-bezier(.34,1.56,.64,1) both}@keyframes kitfx-pg-pop{from{opacity:0;transform:scale(.6)}}' +
'.kitfx-pg-spin{animation:kitfx-pg-spin .8s linear infinite}@keyframes kitfx-pg-spin{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-pg-in .kitfx-pg-part,.kitfx-pg-roll,.kitfx-pg-pop{animation:none}.kitfx-pg-spin{animation-duration:2.4s}}';
const SPRING = 'cubic-bezier(0.34, 1.25, 0.64, 1)';
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
/** Page numbers with gaps: 1 … 4 5 6 … 12 */
export function pageRange(page: number, total: number, siblings = 1): (number | 'gap')[] {
const size = siblings * 2 + 5;
if (total <= size) return Array.from({ length: total }, (_, i) => i + 1);
const start = Math.max(2, Math.min(page - siblings, total - siblings * 2 - 2));
const end = Math.min(total - 1, Math.max(page + siblings, siblings * 2 + 3));
const out: (number | 'gap')[] = [1];
if (start > 2) out.push('gap');
for (let i = start; i <= end; i++) out.push(i);
if (end < total - 1) out.push('gap');
out.push(total);
return out;
}
/** Entrance only for elements that were below the fold, so nothing above it blinks on load */
function useEntrance<T extends Element>() {
const [ref, inView] = useInView<T>();
const waited = useRef(false);
if (!inView) waited.current = true;
return [ref, inView, inView && waited.current] as const;
}
export interface PaginationProps {
page?: number;
defaultPage?: number;
total: number;
onPageChange?: (page: number) => void;
/** Build real links for each page, e.g. (p) => `?page=${p}` */
href?: (page: number) => string;
/** numbers: buttons for each page. simple: previous and next with "Page 3 of 12". */
variant?: 'numbers' | 'simple';
/** Results line on the left, e.g. { from: 21, to: 40, count: 248 } */
summary?: { from: number; to: number; count: number };
labels?: Partial<Texts>;
className?: string;
}
const item = 'relative inline-flex h-10 min-w-10 items-center justify-center rounded-lg px-2 text-sm tabular-nums transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring';
/**
* Page navigation. On phones the numbers turn into "3 / 12" between the arrows.
* The highlight glides to the chosen page, numbers slide to their new places.
* <Pagination total={12} defaultPage={5} summary={{ from: 41, to: 50, count: 118 }} />
*/
export function Pagination({ page: pageProp, defaultPage = 1, total, onPageChange, href, variant = 'numbers', summary, labels, className }: PaginationProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const [inner, setInner] = useState(defaultPage);
const page = pageProp ?? inner;
const fmt = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const go = (p: number) => {
const next = Math.min(total, Math.max(1, p));
setInner(next);
onPageChange?.(next);
};
const [ref, inView, entering] = useEntrance<HTMLElement>();
const listRef = useRef<HTMLUListElement>(null);
const markRef = useRef<HTMLSpanElement>(null);
const [mark, setMark] = useState<{ x: number; w: number } | null>(null);
const places = useRef<Map<string, number> | null>(null);
const range = pageRange(page, total);
// One highlight for the current page that glides, and numbers that glide to their new place (FLIP)
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const measure = () => {
const cur = list.querySelector<HTMLElement>('[aria-current="page"]');
setMark((m) => {
if (!cur || !cur.offsetWidth) return null;
const next = { x: cur.offsetLeft, w: cur.offsetWidth };
return m && m.x === next.x && m.w === next.w ? m : next;
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(list);
return () => ro.disconnect();
}, [page]);
const prevMark = useRef(mark);
useLayoutEffect(() => {
const el = markRef.current;
const prev = prevMark.current;
prevMark.current = mark;
if (!el || !mark || !prev || prefersReducedMotion() || (prev.x === mark.x && prev.w === mark.w)) return;
el.animate([{ transform: `translateX(${prev.x}px) scaleX(${prev.w / mark.w})` }, { transform: `translateX(${mark.x}px)` }], { duration: 440, easing: SPRING });
}, [mark]);
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const nodes = Array.from(list.querySelectorAll<HTMLElement>(':scope > li[data-key]'));
const now = new Map(nodes.map((n) => [n.dataset.key!, n.offsetLeft]));
const before = places.current;
places.current = now;
if (!before || prefersReducedMotion()) return;
nodes.forEach((n) => {
const was = before.get(n.dataset.key!);
const x = now.get(n.dataset.key!)!;
if (was === undefined) n.animate([{ opacity: 0, transform: 'scale(0.7)' }, { opacity: 1, transform: 'none' }], { duration: 320, easing: EASE });
else if (was !== x) n.animate([{ transform: `translateX(${was - x}px)` }, { transform: 'none' }], { duration: 380, easing: EASE });
});
}, [page]);
const btn = ({ to, label, children, className: cls, current, key }: { to: number; label?: string; children: React.ReactNode; className?: string; current?: boolean; key?: string }) => {
const disabled = to < 1 || to > total || (to === page && !current);
const common = cn(item, cls, disabled && 'pointer-events-none opacity-40');
if (href && !disabled)
return (
<a key={key} href={href(to)} aria-label={label} aria-current={current ? 'page' : undefined} onClick={() => go(to)} className={common}>
{children}
</a>
);
return (
<button key={key} type="button" aria-label={label} aria-current={current ? 'page' : undefined} disabled={disabled} onClick={() => go(to)} className={common}>
{children}
</button>
);
};
const arrows = (side: 'prev' | 'next', withText: boolean) =>
btn({
to: side === 'prev' ? page - 1 : page + 1,
label: withText ? undefined : t[side],
className: cn('group/arrow gap-1.5 border border-border bg-background text-foreground shadow-[var(--shadow-sm)] transition-[background-color,translate] hover:bg-accent active:translate-y-px', withText && 'px-3 max-sm:px-0 max-sm:min-w-10'),
children: (
<>
{side === 'prev' && <Icon name="chevron-left" size={16} className="transition-transform duration-200 ease-[var(--ease-kit)] group-hover/arrow:-translate-x-0.5 motion-reduce:transition-none" />}
{withText && <span className="max-sm:sr-only">{t[side]}</span>}
{side === 'next' && <Icon name="chevron-right" size={16} className="transition-transform duration-200 ease-[var(--ease-kit)] group-hover/arrow:translate-x-0.5 motion-reduce:transition-none" />}
</>
),
});
const status = (
<span className="px-2 text-sm tabular-nums text-muted-foreground" aria-live="polite">
{t.page}{' '}
<span key={page} className="kitfx-pg-roll font-medium text-foreground">
{fmt(page)}
</span>{' '}
{t.of} {fmt(total)}
</span>
);
return (
<nav ref={ref} aria-label={t.label} className={cn('flex w-full flex-wrap items-center gap-x-6 gap-y-3', summary ? 'justify-between' : 'justify-center', !inView && '[&_.kitfx-pg-part]:opacity-0', entering && 'kitfx-pg-in', className)}>
<style href="kitfx-pg" precedence="default">
{STYLES}
</style>
{summary && (
<p className="kitfx-pg-part text-sm text-muted-foreground max-sm:hidden">
{t.showing} <span className="font-medium tabular-nums text-foreground">{fmt(summary.from)}</span> {t.to} <span className="font-medium tabular-nums text-foreground">{fmt(summary.to)}</span> {t.of}{' '}
<span className="font-medium tabular-nums text-foreground">{fmt(summary.count)}</span> {t.results}
</p>
)}
{variant === 'simple' ? (
<div className="kitfx-pg-part flex items-center gap-2 max-sm:w-full max-sm:justify-between" style={{ '--i': 1 } as React.CSSProperties}>
{arrows('prev', true)}
{status}
{arrows('next', true)}
</div>
) : (
<div className="flex items-center gap-1 max-sm:w-full max-sm:justify-between">
<span className="kitfx-pg-part" style={{ '--i': 1 } as React.CSSProperties}>
{arrows('prev', true)}
</span>
<ul ref={listRef} className="relative isolate flex items-center gap-1 max-sm:hidden">
{mark && (
<span
ref={markRef}
aria-hidden
className="pointer-events-none absolute left-0 top-0 -z-10 h-10 origin-left rounded-lg bg-primary shadow-[var(--shadow-sm)]"
style={{ width: mark.w, transform: `translateX(${mark.x}px)` }}
/>
)}
{range.map((p, i) =>
p === 'gap' ? (
<li key={`g${i < 3 ? 'a' : 'b'}`} data-key={`g${i < 3 ? 'a' : 'b'}`} aria-hidden className="kitfx-pg-part grid h-10 w-8 place-items-center text-subtle-foreground" style={{ '--i': i + 2 } as React.CSSProperties}>
<Icon name="more-horizontal" size={16} />
</li>
) : (
<li key={p} data-key={p} className="kitfx-pg-part" style={{ '--i': i + 2 } as React.CSSProperties}>
{btn({
to: p,
current: p === page,
label: `${t.page} ${p}`,
className: p === page ? cn('font-medium text-primary-foreground', !mark && 'bg-primary shadow-[var(--shadow-sm)]') : 'text-muted-foreground hover:bg-accent hover:text-foreground',
children: p,
})}
</li>
),
)}
</ul>
<span className="sm:hidden">{status}</span>
<span className="kitfx-pg-part" style={{ '--i': range.length + 2 } as React.CSSProperties}>
{arrows('next', true)}
</span>
</div>
)}
</nav>
);
}
export interface LoadMoreProps {
shown: number;
count: number;
onLoadMore?: () => void;
loading?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/**
* Progress through a long list and one button for more. For feeds and shops.
* The bar fills as more arrives, the button turns into a spinner while loading,
* and the end is marked with a check instead of a missing button.
*/
export function LoadMore({ shown, count, onLoadMore, loading, labels, className }: LoadMoreProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const fmt = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const share = Math.min(1, Math.max(0, shown / (count || 1)));
const pct = Math.round(share * 100);
const [ref, inView, entering] = useEntrance<HTMLDivElement>();
return (
<div ref={ref} className={cn('mx-auto grid w-full max-w-[16rem] justify-items-center gap-4 text-center', !inView && '[&_.kitfx-pg-part]:opacity-0', entering && 'kitfx-pg-in', className)}>
<style href="kitfx-pg" precedence="default">
{STYLES}
</style>
<p className="kitfx-pg-part text-sm text-muted-foreground" aria-live="polite">
<span key={shown} className="kitfx-pg-roll font-medium tabular-nums text-foreground">
{fmt(shown)}
</span>{' '}
{t.of} {fmt(count)} {t.loaded}
</p>
<div className="kitfx-pg-part h-1 w-full overflow-hidden rounded-full bg-track" style={{ '--i': 1 } as React.CSSProperties} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label={`${fmt(shown)} ${t.of} ${fmt(count)}`}>
<div
data-clip-ok
className="h-full w-full rounded-full bg-foreground transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transform: `translateX(${inView ? (share - 1) * 100 : -100}%)`, transitionDelay: entering ? '250ms' : undefined }}
/>
</div>
{shown < count ? (
<button
type="button"
onClick={onLoadMore}
disabled={loading}
aria-busy={loading || undefined}
style={{ '--i': 2 } as React.CSSProperties}
className="kitfx-pg-part group/more inline-flex h-11 items-center gap-2 rounded-lg border border-border-strong bg-background px-5 text-sm font-medium text-foreground shadow-[var(--shadow-sm)] transition-[background-color,translate,opacity] duration-200 hover:bg-accent active:translate-y-px disabled:opacity-60"
>
<span className="relative grid size-4 place-items-center">
<Icon name="arrow-down" size={16} className={cn('transition-[opacity,translate] duration-200 ease-[var(--ease-kit)] group-hover/more:translate-y-0.5 motion-reduce:transition-none', loading && 'translate-y-1 opacity-0')} />
{loading && (
<svg viewBox="0 0 16 16" className="kitfx-pg-spin absolute size-4" aria-hidden>
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2" />
<path d="M8 2a6 6 0 0 1 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
</span>
{loading ? t.loading : t.more}
</button>
) : (
<p className="kitfx-pg-pop inline-flex h-11 items-center gap-2 text-sm font-medium text-foreground">
<span className="grid size-5 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground">
<Icon name="check" size={13} strokeWidth={2.4} />
</span>
{t.all}
</p>
)}
</div>
);
}Frequent questions
How do I install Pagination?
With aiw add @ai-website/pagination. The command puts components/ui/pagination.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Pagination have?
2 variants, Numbers and Load more.
Can I translate the texts of Pagination?
Yes. Pagination carries 15 texts of its own in English and German, such as “Pagination”, “Previous” and “Next”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Pagination?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Pagination with aiw_components and install it with aiw_add @ai-website/pagination.