Pagination für React
Zahlen mit Lücken, schlicht zurück und weiter, eine Ergebniszeile und Mehr laden. Kompakt auf dem Telefon.
Eine von 6 Components in Navigation
Zahlen oder Mehr laden
Eine Pagination führt in Stücken durch lange Listen. Diese Component baut jede Seite als echten Link, Suchmaschinen können ihnen also folgen, zeigt Lücken, wo Seiten übersprungen werden, und hält erste, letzte und die Nachbarn der aktuellen Seite im Blick.
„Zahlen“ ist der Klassiker für Suchergebnisse, Blogs und Tabellen, mit Zurück und Weiter und einer Zeile wie 21 bis 40 von 312. Am Handy schrumpft sie auf Zurück, aktuelle Seite und Weiter. „Mehr laden“ hängt die nächste Menge unter die Liste und setzt den Fokus auf den ersten neuen Eintrag, passend für Produktgrids und Feeds.
Nimm Zahlen, wenn Menschen an eine Stelle zurückkehren, etwa Seite drei eines Katalogs. Nimm Mehr laden, wenn sie stöbern. Halte die Seitengröße stabil und sag, wie viele Ergebnisse es gibt, das hilft bei der Entscheidung, die Suche zu verfeinern.
Details
Was Seitenzahlen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Navigation
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/pagination.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 15 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PaginationProps aus components/ui/pagination.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
pageoptionalnumberdefaultPageoptionalnumbertotalPflichtnumberonPageChangeoptional(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 }
Eingebaute Texte
Diese Texte schreibt Seitenzahlen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 15 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| label | Englisch Pagination | Deutsch Seitennavigation |
| prev | Englisch Previous | Deutsch Zurück |
| next | Englisch Next | Deutsch Weiter |
| page | Englisch Page | Deutsch Seite |
| of | Englisch of | Deutsch von |
| first | Englisch First page | Deutsch Erste Seite |
| last | Englisch Last page | Deutsch Letzte Seite |
| showing | Englisch Showing | Deutsch Zeige |
| to | Englisch to | Deutsch bis |
| results | Englisch results | Deutsch Ergebnissen |
| perPage | Englisch Rows per page | Deutsch Zeilen pro Seite |
| more | Englisch Load more | Deutsch Mehr laden |
| loading | Englisch Loading | Deutsch Wird geladen |
| loaded | Englisch shown | Deutsch angezeigt |
| all | Englisch All shown | Deutsch Alles angezeigt |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-busy, aria-current, aria-label, aria-live, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle progressbar.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 298 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Seitenzahlen?
Mit aiw add @ai-website/pagination. Der Befehl legt die Datei components/ui/pagination.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Seitenzahlen?
2 Varianten, Zahlen und Mehr laden.
Kann ich die Texte von Seitenzahlen übersetzen?
Ja. Seitenzahlen bringt 15 eigene Texte auf Deutsch und Englisch mit, etwa „Seitennavigation“, „Zurück“ und „Weiter“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Seitenzahlen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Seitenzahlen mit aiw_components und installieren sie mit aiw_add @ai-website/pagination.