Pagination
Actual subsets of a bounded collection, with separate requested and loaded pages, truthful current state and native addressable links.
Choose a page model
Use pagination for a bounded collection when users benefit from a stable position, returning to a page or sharing a destination. Use a documented cursor/load-more model for streams without reliable totals. Page controls do not create a collection, fetch data or guarantee a total count.
Use PaginationButton for local view commands and PaginationLink for addressable destinations. Native anchors preserve copying, modified clicks and browser navigation. Keep requested page distinct from the loaded/current page until the corresponding result is actually available.
- Name each collection’s navigation. Give page controls meaningful names and expose exactly one current page per nonempty collection.
- Show useful result range/total when known. Never invent totals or represent an incomplete dataset as complete.
- When filtering or page size changes, deliberately reset/clamp the selected page and reconcile results/current state together.
Anatomy and specifications
| Part | Actual contract |
|---|---|
| Pagination / Content / Item | Native named nav, wrapping ul and presentation li. Containers do not take button visuals or selection state. |
| PaginationButton / Link | Native button/a with minimum 44px target, 8px × 12px padding, 2px readable boundary, medium radius and wrapping text. |
| Current page | isActive supplies aria-current=page and immediate foreground/background inversion with bold text. Current appearance follows loaded results, not pending request. |
| Previous / Next | Native buttons default type=button and useful names, with decorative 16px direction icons. Disabled controls reject activation and leave the native Tab order. |
| Ellipsis | Noninteractive aria-hidden span, minimum 44px slot. It communicates omitted visual range only; it provides no hidden-page access. |
| Focus / RTL / colors | Independent 2px outline; RTL mirrors default previous/next icons. Forced colors retain boundary/current/focus cues. Current contrast flip has no interpolated transition. |
Requested pages, retained records and recovery
Request a different local page, then explicitly complete, fail or cancel. Loaded records/current-page state remain until completion. Failure retains them and offers retry; outcome actions return focus to the surviving result heading. These controls are deliberately inside a form to demonstrate that paging commands do not implicitly submit it. Empty and small collection controls reset the actual page model while preserving the immutable original fixture.
Local results, page 1 of 5
- English-only local record 1
- English-only local record 2
- English-only local record 3
- English-only local record 4
Showing 1–4 of 17 local records. Requested and loaded pages are separate states.
Local page 1 loaded. No remote request occurred.
Addressable pages, direct entry and browser history
Native PaginationLink anchors address the same immutable English-only fixture through demoPage. The query is validated as a bounded integer; invalid input is visibly explained and falls back to page 1. The static result region survives outside the Next useSearchParams Suspense boundary as a real fragment/focus destination. Use this source’s AddressablePaginationExample wrapper when composing it into a static Next page.
Loading the addressable local fixture…
'use client';
import { Suspense, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Button, Pagination, PaginationButton, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@mobtranslate/ui';
const records = Array.from({length:17}, (_, index) => `English-only local record ${index + 1}`);
const pageSize = 4;
export function LocalPaginationExample() {
const [page, setPage] = useState(1);
const [requested, setRequested] = useState<number | null>(null);
const [pending, setPending] = useState(false);
const [failed, setFailed] = useState(false);
const [empty, setEmpty] = useState(false);
const [small, setSmall] = useState(false);
const [rtl, setRtl] = useState(false);
const [result, setResult] = useState('Local page 1 loaded. No remote request occurred.');
const heading = useRef<HTMLHeadingElement>(null);
const available = empty ? [] : small ? records.slice(0,3) : records;
const pages = Math.ceil(available.length / pageSize);
const shown = available.slice((page-1)*pageSize,page*pageSize);
function request(target: number) {
if (pending || target === page || target < 1 || target > pages) return;
setRequested(target); setPending(true); setFailed(false);
setResult(`Local page ${target} requested; loaded page ${page} remains visible.`); heading.current?.focus();
}
function resolve(outcome: 'complete' | 'fail' | 'cancel') {
if (!pending || requested === null) return;
if (outcome === 'complete') { setPage(requested); setRequested(null); setResult(`Local page ${requested} loaded. No remote request occurred.`); }
if (outcome === 'fail') { setFailed(true); setResult(`Local page ${requested} failed. Loaded page ${page} and its records remain; retry is available.`); }
if (outcome === 'cancel') { setRequested(null); setResult(`Local page request cancelled. Loaded page ${page} remains.`); }
setPending(false); heading.current?.focus();
}
return <div data-pagination-lab className="space-y-4">
<div className="flex flex-wrap gap-3"><Button type="button" variant="outline" disabled={pending} aria-pressed={empty} onClick={() => { setEmpty(current => !current); setPage(1); setFailed(false); setRequested(null); setResult(empty ? 'Local collection restored; page 1 loaded.' : 'Local collection view is empty; no page selected.'); }}>Toggle empty local collection</Button><Button type="button" variant="outline" disabled={pending || empty} aria-pressed={small} onClick={() => { setSmall(current => !current); setPage(1); setFailed(false); setRequested(null); setResult(small ? 'Full local collection restored; page 1 loaded.' : 'Small local collection loaded on page 1.'); }}>Use small local collection</Button><Button type="button" variant="outline" aria-pressed={rtl} onClick={() => setRtl(current => !current)}>Use RTL local pagination</Button></div>
<h3 id="local-paging-heading" ref={heading} tabIndex={-1} className="font-semibold">{pages ? `Local results, page ${page} of ${pages}` : 'No local records'}</h3>
{shown.length ? <ol data-local-page-records start={(page-1)*pageSize+1} className="guidance-list">{shown.map(record => <li key={record}>{record}</li>)}</ol> : <p>Restore the local collection with the empty-collection control. No source data was deleted.</p>}
<p id="local-paging-help">{pages ? `Showing ${(page-1)*pageSize+1}–${Math.min(page*pageSize,available.length)} of ${available.length} local records.` : 'Zero local records; no page is selected.'} Requested and loaded pages are separate states.</p>
{pages > 0 && <form onSubmit={event => { event.preventDefault(); setResult('Unexpected pagination form submission.'); }}><div dir={rtl ? 'rtl' : 'ltr'}><Pagination aria-label="Local record pages" aria-describedby="local-paging-help"><PaginationContent>
<PaginationItem><PaginationPrevious disabled={pending || page === 1} onClick={() => request(page-1)} /></PaginationItem>
{Array.from({length:pages},(_,index)=>index+1).map(target=><PaginationItem key={target}><PaginationButton aria-label={`Local page ${target}`} isActive={target === page} disabled={pending} onClick={()=>request(target)}>{target}</PaginationButton></PaginationItem>)}
<PaginationItem><PaginationNext disabled={pending || page === pages} onClick={()=>request(page+1)} /></PaginationItem>
</PaginationContent></Pagination></div></form>}
{pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={()=>resolve('complete')}>Complete local page load</Button><Button type="button" variant="outline" onClick={()=>resolve('fail')}>Fail local page load</Button><Button type="button" variant="outline" onClick={()=>resolve('cancel')}>Cancel local page load</Button></div>}
{failed && requested !== null && <Button type="button" variant="outline" onClick={()=>request(requested)}>Retry requested local page</Button>}
<p role="status">{result}</p>
</div>;
}
export function RoutePaginationExample() {
const search = useSearchParams();
const raw = search.get('demoPage');
const parsed = Number(raw);
const count = Math.ceil(records.length/pageSize);
const valid = raw === null || (Number.isInteger(parsed) && parsed >= 1 && parsed <= count);
const page = valid && raw !== null ? parsed : 1;
function href(target: number) { const next = new URLSearchParams(search.toString()); next.set('demoPage',String(target)); return `/components/pagination?${next.toString()}#routed-local-results`; }
return <div data-route-pagination-lab className="space-y-3">
<p data-route-page>Addressable local collection: page {page} of {count}.</p>
{!valid && <p role="status">The requested URL page is invalid; showing local page 1. Choose a valid page link to correct the URL.</p>}
<ol className="guidance-list" start={(page-1)*pageSize+1}>{records.slice((page-1)*pageSize,page*pageSize).map(record=><li key={record}>{record}</li>)}</ol>
<Pagination aria-label="Addressable local record pages"><PaginationContent>
{Array.from({length:count},(_,index)=>index+1).map(target=><PaginationItem key={target}><PaginationLink href={href(target)} aria-label={`Open route page ${target}`} isActive={target === page}>{target}</PaginationLink></PaginationItem>)}
</PaginationContent></Pagination>
<p>These native links address the same immutable local fixture through a validated query. They support direct entry, history and modified clicks; no backend collection or retained draft is implied.</p>
<p>Ellipsis specimen, separate from this complete five-page range: <PaginationEllipsis />. Hidden visual punctuation is not a page or a substitute for access to missing pages.</p>
</div>;
}
export function AddressablePaginationExample() {
return <div id="routed-local-results" tabIndex={-1} role="region" aria-label="Addressable local results"><Suspense fallback={<p>Loading the addressable local fixture…</p>}><RoutePaginationExample /></Suspense></div>;
}
API reference and ownership
| Export / prop | Contract |
|---|---|
| Pagination native nav props | Default aria-label Pagination; supply collection-specific aria-label/labelledby/describedby as needed. HTMLElement ref. |
| PaginationContent / PaginationItem | Native ul/li props and refs. Caller supplies controls/range; no automatic page algorithm. |
| PaginationButton isActive / type / disabled / onClick | false / button defaults; caller computes loaded page and actual action. Native disabled enforcement; explicit type may override for a deliberate form action. |
| PaginationLink isActive / href / target / rel / onClick | Native anchor counterpart with current-page styling. No disabled prop or automatic interception; unavailable routes need truthful non-link content/guarded policy. |
| PaginationPrevious / PaginationNext | Native button props, default type=button and default meaningful aria-label. Custom children/labels can supply localized wording while SVG remains decorative. |
| PaginationEllipsis | Native span props with aria-hidden default. No handler, focus or hidden-page generation. |
| className / style / ref | Native contracts and additional string classes; no Base UI render/state callback API. |
| Collection/count/loading/URL state | Entirely caller-owned. The local requested/loaded state machine and validated native link fixture are checked examples, not a remote data provider. |
States and task invariants
| State | Required behavior |
|---|---|
| Loaded / current | Current page and visible records agree; selected styling alone is insufficient. |
| First / last page | Previous/next availability follows actual range. Reject out-of-range requests without mutating loaded records. |
| Requested / pending | Preserve loaded content, prevent duplicate requests and announce requested versus retained state. This lab waits for an explicit outcome. |
| Failure / retry | Retain records/current page and a retry target. A failed provider response must not promote the requested page to loaded/current. |
| Cancelled | Keep loaded results; distinguish cancellation from completion. Restore focus when transient outcome controls disappear. |
| Empty / count changed | No selected page for zero records. Offer recovery and reconcile page/count/results together after filtering. |
| Invalid/deep-linked URL | Validate against the actual collection model, explain fallback and provide a real correction link. Native query state is not a permission or persistence claim. |
| Unknown total / stale result | Use honest cursor/loading states and request/version checks. This fixture has a fixed known total and does not certify remote racing requests. |
Keyboard, adaptive behavior and common failures
- Tab/Shift+Tab visits available native controls; Enter/Space activates buttons and Enter follows links. Pagination is not a tab-list or arrow-key composite.
- Ensure page buttons do not accidentally submit enclosing forms. The shared default is now type=button; applications needing submit must opt in.
- At narrow widths, controls wrap in DOM order and preserve minimum targets. Long navigation names wrap; do not compress numbers into unreadable hit areas.
- Disabled native buttons suppress interaction; an anchor with a disabled appearance remains a destination. Current page remains selected even while paging actions are temporarily disabled.
- Do not style list-item containers as if they were emitted page buttons. The stylesheet now targets mt-pagination-btn and mt-pagination-btn-active.
- Ellipsis alone cannot make omitted pages reachable. Use a meaningful visible range, first/last/page-jump or accessible load model and test every destination.
- Native page links need actual URL/history/current-state and arrival checks. A local button example supplies none of those automatically.
Verification and release scope
- Follow actual destinations and read actual records. Check native link/button semantics, names, current state, focus, unavailable activation and retained versus requested outcomes. Appearance or callbacks alone are insufficient.
- Test both themes, long labels, 320px reflow, RTL/orientation, zoom/text spacing, reduced motion and forced colors. Device touch and spoken announcements need named platform evidence.
- Run test:destinations, checked-source/token freshness, lint, UI/styleguide typechecks, production build and route/fragment crawl. The suite supplies scoped browser evidence, not whole-library certification.
- The English-only local fixture establishes no source quality, cultural authority, permission, authenticated access, persistent write or remote collection result.