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.

pagination-links.tsx
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.

pagination-prefetch.tsx
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-ink is 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-ink points straight at --primary (#E8B824, the same gold as gold-300), which measures 1.86:1 on the page. That is the same figure styles.css records 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 to gold-500 (#7A5800): 6.51:1 against the page fill and 5.96:1 against the gold tint it outlines. In dark, --primary-ink follows the lighter --primary (#F5D060) at 12.67:1 in both palettes.
  • That swap is the entire reason the class is border-primary-ink and not border-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" and data-active are 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

PropTypeDefaultDescription
PaginationReact.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.
PaginationContentReact.ComponentProps<'ul'>The <ul> list that lays out the page items in a horizontal row.
PaginationItemReact.ComponentProps<'li'>A single <li> wrapper for a link, previous/next control, or ellipsis.
PaginationLink — isActivebooleanfalseMarks 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 — ...restReact.ComponentProps<'a'>All native anchor props (href, onClick, aria-label…) are forwarded. There is no asChild on this primitive.
PaginationPrevious — labelReact.ReactNode'Trước'Visible label beside the chevron, hidden below the sm breakpoint. Pass an English string.
PaginationNext — labelReact.ReactNode'Sau'Visible label beside the chevron, hidden below the sm breakpoint. Pass an English string.
PaginationEllipsis — srLabelstring'Thêm trang'Visually hidden text inside the gap indicator; the span itself is aria-hidden.

Accessibility

  • The root renders a <nav> with an aria-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.
  • PaginationPrevious and PaginationNext carry descriptive aria-labels, keeping the controls readable below the sm breakpoint where the text label is hidden and only the chevron shows.
  • PaginationEllipsis is aria-hidden so the decorative gap is skipped during navigation; its srLabel is the visually-hidden fallback text.
  • Numbered links read as bare digits. Give each one an aria-label such as "Page 3" when the list is announced out of context.