Pagination
Page numbers that are real links, not click handlers, so they work with no JavaScript and a crawler follows every page.
server - 0 KB
Pagination
server - 0 KBPage numbers that are real links, not click handlers, so they work with no JavaScript and a crawler follows every page.
Try it
value
pageCount
siblingCount
boundaryCount
size
The code
pagination.tsx
import { Pagination } from 'topclassui/pagination'
<Pagination value={6} pageCount={12} getHref={(page) => `/blog?page=${String(page)}`} />getHref keeps every page crawlable; value, siblingCount and boundaryCount are clamped to a safe range.
Every state
4 statesImport from the package
topclassui/pagination
import { Pagination } from 'topclassui/pagination'Install once:npm i topclassui@beta zod
Examples
- Pass
value,pageCountandgetHref, which turns a page number into its address, such as /blog?page=3. Every cell is an anchor. - In Next.js a plain link reloads the page. Pass
link={<Link href="" />}and each page change is a client navigation. - A bad page in the URL is clamped for what is shown:
?page=999of 12 shows page 12. Query that same page withclampPage(Number(page), pageCount). siblingCountandboundaryCount(0 to 3, default 1) set how many pages show around the current one and at each end.size="md"is 40 px, the height of a Button;size="sm"is the 32 px cell of the design.customemits no size classes.- The row wraps instead of scrolling. On a phone pass
siblingCount={0}andboundaryCount={0}for a shorter row. paginationVariants({ size, current })returns the classes of one cell, for a link you render yourself.
middle
middle.tsx
import { Pagination } from 'topclassui/pagination'
const blog = (page: number) => `/blog?page=${String(page)}`
/** Away from both ends: one sibling each side, one page at each end and a gap between. */
export default function Demo() {
return <Pagination value={6} pageCount={12} getHref={blog} />
}
first-page
first-page.tsx
import { Pagination } from 'topclassui/pagination'
const blog = (page: number) => `/blog?page=${String(page)}`
/** On page 1 there is nowhere to go back to, so first and previous are spans, not links. */
export default function Demo() {
return <Pagination value={1} pageCount={12} getHref={blog} />
}
last-page
last-page.tsx
import { Pagination } from 'topclassui/pagination'
const blog = (page: number) => `/blog?page=${String(page)}`
/** On the last page next and last are the spans; the row is still seven pages long. */
export default function Demo() {
return <Pagination value={12} pageCount={12} getHref={blog} />
}
sizes
sizes.tsx
import { Pagination } from 'topclassui/pagination'
const blog = (page: number) => `/blog?page=${String(page)}`
/** Two navs on one page need a name each, so every one here passes its own aria-label. */
export default function Demo() {
return (
<div className="flex flex-col gap-4">
<Pagination size="xs" value={3} pageCount={12} getHref={blog} aria-label="Extra small" />
<Pagination size="sm" value={3} pageCount={12} getHref={blog} aria-label="Small" />
<Pagination size="md" value={3} pageCount={12} getHref={blog} aria-label="Medium" />
<Pagination size="lg" value={3} pageCount={12} getHref={blog} aria-label="Large" />
<Pagination size="xl" value={3} pageCount={12} getHref={blog} aria-label="Extra large" />
</div>
)
}
many-pages
many-pages.tsx
import { Pagination } from 'topclassui/pagination'
const orders = (page: number) => `/orders?page=${String(page)}`
/** 500 pages are still eleven cells: two at each end, two each side of 250 and two gaps. */
export default function Demo() {
return <Pagination value={250} pageCount={500} siblingCount={2} boundaryCount={2} getHref={orders} />
}
two-navs
Orders 26 to 50 of 200
two-navs.tsx
import { Pagination } from 'topclassui/pagination'
const orders = (page: number) => `/orders?page=${String(page)}`
/** Above and below one list: two navs on a page need a name each, so each passes aria-label. */
export default function Demo() {
return (
<div className="flex flex-col gap-4">
<Pagination value={2} pageCount={8} getHref={orders} aria-label="Orders pages, top" />
<p className="text-sm text-muted">Orders 26 to 50 of 200</p>
<Pagination value={2} pageCount={8} getHref={orders} aria-label="Orders pages, bottom" />
</div>
)
}
custom-size
custom-size.tsx
import { Pagination } from 'topclassui/pagination'
const blog = (page: number) => `/blog?page=${String(page)}`
/** custom emits no size: a cell falls to the 24 px floor and takes the type size of the nav. */
export default function Demo() {
return (
<div className="flex flex-col gap-4">
<Pagination value={3} pageCount={12} getHref={blog} aria-label="The scale" />
<Pagination size="custom" className="text-xs" value={3} pageCount={12} getHref={blog} aria-label="Your own cells" />
</div>
)
}
wide-numbers
wide-numbers.tsx
import { Pagination } from 'topclassui/pagination'
const orders = (page: number) => `/orders?page=${String(page)}`
/** Page 12,345 of 123,456: a cell grows with its number, so no digit leaves the box. */
export default function Demo() {
return (
<div className="flex flex-col gap-4">
<Pagination size="xs" value={12345} pageCount={123456} getHref={orders} aria-label="Extra small" />
<Pagination size="sm" value={12345} pageCount={123456} getHref={orders} aria-label="Small" />
<Pagination value={12345} pageCount={123456} getHref={orders} aria-label="Medium" />
</div>
)
}
In Arabic, right to left
rtl.tsx
import { ar } from 'topclassui/labels/ar'
import { Pagination } from 'topclassui/pagination'
const blog = (page: number) => `/blog?page=${String(page)}`
/** The row runs from the right and the four carets flip, with no class of its own for it. */
export default function Demo() {
return (
<div dir="rtl" lang="ar">
<Pagination value={3} pageCount={12} getHref={blog} labels={ar} locale="ar" />
</div>
)
}
Props
Keyboard and accessibility
- The current page carries
aria-current="page"and is named "Page 3 of 12"; every other cell is named "Page 4". - Previous and next carry
rel="prev"andrel="next". On the first and the last page that control is disabled and has no href. - Two navs on one page each need their own
aria-label, such as "Orders pages, top" and "Orders pages, bottom".
Right to left
Under dir="rtl" the row runs from the right and the four carets flip. {...getLabels('ar')} passes the Arabic labels and locale together.
Server and client
Renders on the server and ships no JavaScript.
Files: index.tsx.
On the chip: server - 0 KB.