MobTranslate styleguide

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

PartActual contract
Pagination / Content / ItemNative named nav, wrapping ul and presentation li. Containers do not take button visuals or selection state.
PaginationButton / LinkNative button/a with minimum 44px target, 8px × 12px padding, 2px readable boundary, medium radius and wrapping text.
Current pageisActive supplies aria-current=page and immediate foreground/background inversion with bold text. Current appearance follows loaded results, not pending request.
Previous / NextNative buttons default type=button and useful names, with decorative 16px direction icons. Disabled controls reject activation and leave the native Tab order.
EllipsisNoninteractive aria-hidden span, minimum 44px slot. It communicates omitted visual range only; it provides no hidden-page access.
Focus / RTL / colorsIndependent 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

  1. English-only local record 1
  2. English-only local record 2
  3. English-only local record 3
  4. 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…

tsx
'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 / propContract
Pagination native nav propsDefault aria-label Pagination; supply collection-specific aria-label/labelledby/describedby as needed. HTMLElement ref.
PaginationContent / PaginationItemNative ul/li props and refs. Caller supplies controls/range; no automatic page algorithm.
PaginationButton isActive / type / disabled / onClickfalse / 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 / onClickNative anchor counterpart with current-page styling. No disabled prop or automatic interception; unavailable routes need truthful non-link content/guarded policy.
PaginationPrevious / PaginationNextNative button props, default type=button and default meaningful aria-label. Custom children/labels can supply localized wording while SVG remains decorative.
PaginationEllipsisNative span props with aria-hidden default. No handler, focus or hidden-page generation.
className / style / refNative contracts and additional string classes; no Base UI render/state callback API.
Collection/count/loading/URL stateEntirely 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

StateRequired behavior
Loaded / currentCurrent page and visible records agree; selected styling alone is insufficient.
First / last pagePrevious/next availability follows actual range. Reject out-of-range requests without mutating loaded records.
Requested / pendingPreserve loaded content, prevent duplicate requests and announce requested versus retained state. This lab waits for an explicit outcome.
Failure / retryRetain records/current page and a retry target. A failed provider response must not promote the requested page to loaded/current.
CancelledKeep loaded results; distinguish cancellation from completion. Restore focus when transient outcome controls disappear.
Empty / count changedNo selected page for zero records. Offer recovery and reconcile page/count/results together after filtering.
Invalid/deep-linked URLValidate 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 resultUse 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.