Qure UI
Components

Pagination

Moving through a long result set, one page at a time.

'use client'import {  Pagination, PaginationContent, PaginationItem, PaginationLink,  PaginationNext, PaginationPrevious,} from '@/registry/qure/ui/pagination'export default function PaginationDemo() {  return (    <Pagination>      <PaginationContent>        <PaginationItem>          <PaginationPrevious href="#page-1" />        </PaginationItem>        <PaginationItem>          <PaginationLink href="#page-1">1</PaginationLink>        </PaginationItem>        <PaginationItem>          <PaginationLink href="#page-2" isActive>2</PaginationLink>        </PaginationItem>        <PaginationItem>          <PaginationLink href="#page-3">3</PaginationLink>        </PaginationItem>        <PaginationItem>          <PaginationNext href="#page-3" />        </PaginationItem>      </PaginationContent>    </Pagination>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/pagination.json

Usage

import {
  Pagination, PaginationContent, PaginationEllipsis, PaginationInfo,
  PaginationItem, PaginationLink, PaginationNext, PaginationPrevious,
} from '@/components/ui/pagination'
<Pagination>
  <PaginationContent>
    <PaginationItem><PaginationPrevious href="#page-1" /></PaginationItem>
    <PaginationItem><PaginationLink href="#page-1">1</PaginationLink></PaginationItem>
    <PaginationItem><PaginationLink href="#page-2" isActive>2</PaginationLink></PaginationItem>
    <PaginationItem><PaginationNext href="#page-3" /></PaginationItem>
  </PaginationContent>
</Pagination>

Base UI has no pagination primitive, for the same reason it has no breadcrumb: there is no behaviour to own. A pager is a nav around a list of links, and everything it needs — where am I, where can I go — comes from the markup being right.

Composition

<Pagination>          {/* nav, aria-label="Pagination" */}
  <PaginationContent> {/* ul */}
    <PaginationItem>  {/* li */}
      <PaginationPrevious /> <PaginationNext />  {/* the edges */}
      <PaginationLink />                          {/* one page number */}
      <PaginationEllipsis />                      {/* the pages that were dropped */}
  <PaginationInfo>    {/* "Showing 51–75 of 412" */}
</Pagination>

Two things are easy to get wrong and are therefore built in rather than left to the caller:

  • The current page carries aria-current="page" and is not a link. isActive renders a <span>, because a link to the page you are already on does nothing and every keyboard user has to pass through it.
  • Previous and next keep a text label for assistive technology even when only the chevron is drawn.

Everything renders as an anchor with nativeButton={false}, so middle-click, copy link and open in a new tab all work — which a <button> that navigates would break.

Truncation

'use client'import * as React from 'react'import {  Pagination, PaginationContent, PaginationEllipsis, PaginationItem,  PaginationLink, PaginationNext, PaginationPrevious,} from '@/registry/qure/ui/pagination'const TOTAL = 17/** * Seventeen pages of reports do not fit, so the run is clipped around the * current page and the two ends are always shown — the first and last page * are the two people jump to most. */function pages(current: number, total: number): (number | 'gap')[] {  const window = new Set([1, total, current - 1, current, current + 1])  const shown = [...window].filter(n => n >= 1 && n <= total).sort((a, b) => a - b)  return shown.flatMap((n, i) =>    i > 0 && n - shown[i - 1] > 1 ? ['gap' as const, n] : [n]  )}export default function PaginationTruncated() {  const [page, setPage] = React.useState(8)  return (    <Pagination>      <PaginationContent>        <PaginationItem>          <PaginationPrevious            href={`#page-${Math.max(1, page - 1)}`}            onClick={() => setPage(p => Math.max(1, p - 1))}          />        </PaginationItem>        {pages(page, TOTAL).map((entry, i) =>          entry === 'gap' ? (            <PaginationItem key={`gap-${i}`}>              <PaginationEllipsis />            </PaginationItem>          ) : (            <PaginationItem key={entry}>              <PaginationLink                href={`#page-${entry}`}                isActive={entry === page}                onClick={() => setPage(entry)}              >                {entry}              </PaginationLink>            </PaginationItem>          )        )}        <PaginationItem>          <PaginationNext            href={`#page-${Math.min(TOTAL, page + 1)}`}            onClick={() => setPage(p => Math.min(TOTAL, p + 1))}          />        </PaginationItem>      </PaginationContent>    </Pagination>  )}

Seventeen pages do not fit. Clip the run around the current page and always show both ends: the first and last page are the two people jump to most, and losing them turns a two-click journey into a dozen.

PaginationEllipsis is aria-hidden. It stands for pages that were dropped, which is a fact about the layout rather than about the data.

At the edges

'use client'import * as React from 'react'import {  Pagination, PaginationContent, PaginationItem, PaginationLink,  PaginationNext, PaginationPrevious,} from '@/registry/qure/ui/pagination'const TOTAL = 4/** * At the ends there is nowhere to go, and the control says so with * `aria-disabled` rather than by disappearing. A pager whose shape changes as * you move through it makes the next target land somewhere new every time. */export default function PaginationEdges() {  const [page, setPage] = React.useState(1)  return (    <Pagination>      <PaginationContent>        <PaginationItem>          <PaginationPrevious            href={page === 1 ? undefined : `#page-${page - 1}`}            aria-disabled={page === 1 || undefined}            onClick={() => setPage(p => Math.max(1, p - 1))}          />        </PaginationItem>        {Array.from({ length: TOTAL }, (_, i) => i + 1).map(n => (          <PaginationItem key={n}>            <PaginationLink href={`#page-${n}`} isActive={n === page} onClick={() => setPage(n)}>              {n}            </PaginationLink>          </PaginationItem>        ))}        <PaginationItem>          <PaginationNext            href={page === TOTAL ? undefined : `#page-${page + 1}`}            aria-disabled={page === TOTAL || undefined}            onClick={() => setPage(p => Math.min(TOTAL, p + 1))}          />        </PaginationItem>      </PaginationContent>    </Pagination>  )}

On page one there is nowhere back to go, and the control says so with aria-disabled rather than by vanishing. A pager whose shape changes as you move through it puts the next target somewhere new every time — the reader has to re-aim after every click.

<PaginationPrevious
  href={page === 1 ? undefined : `#page-${page - 1}`}
  aria-disabled={page === 1 || undefined}
/>

aria-disabled rather than disabled on purpose: the control stays focusable, so a keyboard user tabbing along the pager does not silently skip it and wonder where it went.

The count, not the page

'use client'import {  Pagination, PaginationContent, PaginationInfo, PaginationItem,  PaginationLink, PaginationNext, PaginationPrevious,} from '@/registry/qure/ui/pagination'/** * The count matters more than the page number. "Page 3" tells a radiologist * nothing about whether the filter is narrow enough; "1–25 of 412" does. */export default function PaginationSummary() {  return (    <div className="flex w-full flex-col gap-3">      <Pagination className="justify-between">        <PaginationInfo>Showing 51–75 of 412 signed reports</PaginationInfo>        <PaginationContent>          <PaginationItem>            <PaginationPrevious href="#page-2" size="sm" />          </PaginationItem>          <PaginationItem>            <PaginationLink href="#page-2" size="sm">2</PaginationLink>          </PaginationItem>          <PaginationItem>            <PaginationLink href="#page-3" size="sm" isActive>3</PaginationLink>          </PaginationItem>          <PaginationItem>            <PaginationLink href="#page-4" size="sm">4</PaginationLink>          </PaginationItem>          <PaginationItem>            <PaginationNext href="#page-4" size="sm" />          </PaginationItem>        </PaginationContent>      </Pagination>    </div>  )}

PaginationInfo is not decoration. "Page 3" tells a radiologist nothing about whether their filter is narrow enough; "Showing 51–75 of 412 signed reports" does. Where you have room, the total is the more useful of the two numbers.

size="sm" on the controls suits a pager sitting under a dense table.

Compact

'use client'import * as React from 'react'import {  Pagination, PaginationContent, PaginationInfo, PaginationItem,  PaginationNext, PaginationPrevious,} from '@/registry/qure/ui/pagination'const TOTAL = 96/** * A slice viewer does not need thirty page numbers — it needs one step at a * time. `iconOnly` drops the words and keeps the accessible names, so the * controls still announce as "Go to previous page". */export default function PaginationCompact() {  const [slice, setSlice] = React.useState(42)  return (    <Pagination>      <PaginationContent className="gap-2">        <PaginationItem>          <PaginationPrevious            iconOnly            href="#previous-slice"            aria-label="Previous slice"            onClick={() => setSlice(s => Math.max(1, s - 1))}          />        </PaginationItem>        <PaginationItem>          <PaginationInfo>Slice {slice} of {TOTAL}</PaginationInfo>        </PaginationItem>        <PaginationItem>          <PaginationNext            iconOnly            href="#next-slice"            aria-label="Next slice"            onClick={() => setSlice(s => Math.min(TOTAL, s + 1))}          />        </PaginationItem>      </PaginationContent>    </Pagination>  )}

A slice viewer does not need thirty page numbers, it needs one step at a time. iconOnly drops the words and keeps the accessible name, so the controls still announce as "Previous slice".

When iconOnly hides the label, pass an aria-label that says what is being stepped through. The default is "Go to previous page", which is wrong when the thing moving is a slice, a series or a study.

API Reference

Renders an anchor, or a <span> when active.

PropTypeDefaultDescription
isActivebooleanfalseThe page you are on. Renders a span and marks it aria-current="page".
size"sm" | "md""md"Matches the surrounding density.

PaginationPrevious and PaginationNext

PropTypeDefaultDescription
iconOnlybooleanfalseHides the word, keeps the chevron and the accessible name.
size"sm" | "md""md"Matches the surrounding density.

Both also take the usual anchor props — href, onClick, aria-disabled.

Pagination renders a <nav> already labelled "Pagination", PaginationContent a <ul>, PaginationItem an <li>, PaginationEllipsis an aria-hidden <span>, and PaginationInfo a <p>.

On this page