Components
Pagination
Lets users navigate across pages of content with numbered links, previous and next controls, and a truncating ellipsis for long ranges.
These are static primitives — no 'use client', so they render inside a Server Component. They are the scaffolding only: you decide which numbers to show. The version that already knows how to count — page size selector, record range, first/last buttons — is the TablePagination composite, also exported from @comitor/ui.
Basic usage
Compose a Pagination from items, marking the current page with isActive.
Truncated ranges
Drop a PaginationEllipsis between distant page numbers to keep long ranges compact while always showing the first, last, and current pages.
English labels
The package ships Vietnamese defaults: Trước / Sau on the two arrow controls, Thêm trang in the ellipsis, and aria-label="Phân trang" on the root. Each one is a plain prop, and every one of these props is applied before the spread — so passing your own always wins.
Routing
PaginationLink has no asChild escape hatch yet — unlike BreadcrumbLink, it always renders its own <a>. Pass href straight through and it behaves like any link; if you need more, wrap the set in a composite of your own at the app layer.
import { Pagination, PaginationContent, PaginationItem, PaginationLink } from '@comitor/ui'
// PaginationLink has no asChild — it always renders a real <a>. Plain hrefs
// are the simplest route, and they work in a Server Component.
export function Example({ page }: { page: number }) {
return (
<Pagination aria-label="Pagination">
<PaginationContent>
{[1, 2, 3].map((n) => (
<PaginationItem key={n}>
<PaginationLink href={`/invoices?page=${n}`} isActive={n === page} aria-label={`Page ${n}`}>
{n}
</PaginationLink>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
)
}If you want client-side routing and prefetching, render your own Link and reuse the recipe the primitive applies internally — buttonVariants and cn are both exported from the same entry.
import Link from 'next/link'
import { Pagination, PaginationContent, PaginationItem, buttonVariants, cn } from '@comitor/ui'
// Want next/link prefetching instead? Skip PaginationLink and borrow the
// same recipe it uses internally.
export function Example({ page }: { page: number }) {
return (
<Pagination aria-label="Pagination">
<PaginationContent>
{[1, 2, 3].map((n) => (
<PaginationItem key={n}>
<Link
href={`/invoices?page=${n}`}
aria-current={n === page ? 'page' : undefined}
data-active={n === page}
className={cn(
buttonVariants({ variant: 'ghost', size: 'icon' }),
n === page &&
'border-primary-ink bg-gold-50 font-semibold text-foreground hover:bg-gold-50 dark:bg-gold-500/10 dark:hover:bg-gold-500/10'
)}
>
{n}
</Link>
</PaginationItem>
))}
</PaginationContent>
</Pagination>
)
}How the current page is marked
The active link gets a faint gold fill and an accent border, and it is the border that is meant to carry the meaning. bg-gold-50 sits at only 1.09:1 against the page, far under the 3:1 that WCAG 1.4.11 asks of a non-text cue, so on its own it would tell a low-vision reader nothing.
border-primary-inkis a role token, not a fixed swatch, so what it draws depends on which of the package's two palettes is active. In the default one--primary-inkpoints straight at--primary(#E8B824, the same gold asgold-300), which measures 1.86:1 on the page. That is the same figurestyles.cssrecords for the Tabs active underline and the input focus ring, among the shortfalls the approved comitor-ds palette knowingly accepts.- Under
data-contrast="high"the same token repoints togold-500(#7A5800): 6.51:1 against the page fill and 5.96:1 against the gold tint it outlines. In dark,--primary-inkfollows the lighter--primary(#F5D060) at 12.67:1 in both palettes. - That swap is the entire reason the class is
border-primary-inkand notborder-gold-300. In the default light palette the two render the same pixel today; writing the raw scale step would nail the marker to that palette and leave the high-contrast one silently unfixed. aria-current="page"anddata-activeare set alongside. On the default palette they are what actually carries the state, so never let the border be a page's only answer to "which page am I on?".
Props
| Prop | Type | Default | Description |
|---|---|---|---|
Pagination | React.ComponentProps<'nav'> | — | Root <nav>. Its implicit navigation role is left implicit — no redundant role="navigation" — and it carries aria-label="Phân trang" unless you pass your own. |
PaginationContent | React.ComponentProps<'ul'> | — | The <ul> list that lays out the page items in a horizontal row. |
PaginationItem | React.ComponentProps<'li'> | — | A single <li> wrapper for a link, previous/next control, or ellipsis. |
PaginationLink — isActive | boolean | false | Marks the current page: sets aria-current="page", data-active, an accent border and a faint gold fill. |
PaginationLink — size | 'default' | 'md' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg' | 'icon' | Forwarded to buttonVariants, which supplies the ghost-button box. Previous/Next override it to "default" so their text label fits. |
PaginationLink — ...rest | React.ComponentProps<'a'> | — | All native anchor props (href, onClick, aria-label…) are forwarded. There is no asChild on this primitive. |
PaginationPrevious — label | React.ReactNode | 'Trước' | Visible label beside the chevron, hidden below the sm breakpoint. Pass an English string. |
PaginationNext — label | React.ReactNode | 'Sau' | Visible label beside the chevron, hidden below the sm breakpoint. Pass an English string. |
PaginationEllipsis — srLabel | string | 'Thêm trang' | Visually hidden text inside the gap indicator; the span itself is aria-hidden. |
Accessibility
- The root renders a
<nav>with anaria-label, exposing the control as a discoverable landmark.role="navigation"is not written out —<nav>already has it, and restating a native role is redundant ARIA. - The active page link sets
aria-current="page"so assistive tech announces the user's current location, and marks it visually with an accent border rather than the tint alone — see above for what that border measures in each palette. PaginationPreviousandPaginationNextcarry descriptivearia-labels, keeping the controls readable below thesmbreakpoint where the text label is hidden and only the chevron shows.PaginationEllipsisisaria-hiddenso the decorative gap is skipped during navigation; itssrLabelis the visually-hidden fallback text.- Numbered links read as bare digits. Give each one an
aria-labelsuch as"Page 3"when the list is announced out of context.