Open UI

Pagination

Displays a page range and lets users navigate between pages of content. For infinite scroll, consider a "Load more" button pattern instead.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return <Pagination initialPage={1} total={10} />}

Usage

import { Pagination } from "@opengovsg/oui"
<Pagination total={10} onChange={setPage} />

Alternatively, install the component as local source via the shadcn CLI:

npx shadcn@latest add https://oui.open.gov.sg/r/pagination.json
pnpm dlx shadcn@latest add https://oui.open.gov.sg/r/pagination.json
npx shadcn@latest add https://oui.open.gov.sg/r/pagination.json
bunx --bun shadcn@latest add https://oui.open.gov.sg/r/pagination.json

Pagination renders a <nav> landmark with numbered page buttons, optional prev/next controls, and animated ellipsis items for large ranges. It is built on OUI's own usePagination hook (not a React Aria primitive), which manages active-page state and computes the visible page range from total, siblings, and boundaries.

The root <nav> element gets aria-label="pagination navigation" by default; pass a custom aria-label to override it when multiple paginations appear on one page.

Anatomy

Pagination consists of:

  • Pagination — the root <nav> container; manages all state
    • PaginationItem — one interactive page button (number, prev, next, or dots)
    • PaginationCursor — the animated highlight that slides to the active page (internal; hidden when disableCursorAnimation is true)

Examples

Disabled

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return <Pagination isDisabled initialPage={1} total={10} />}

Sizes

Use the size prop to change button dimensions. Defaults to "md".

import { Pagination } from "@opengovsg/oui"export const Example = () => {  const sizes = ["sm", "md", "lg"] as const  return (    <div className="flex flex-wrap items-center gap-4">      {sizes.map((size) => (        <Pagination key={size} initialPage={1} size={size} total={10} />      ))}    </div>  )}

Colors

Use the color prop to change the cursor color. Defaults to "neutral".

import { Pagination } from "@opengovsg/oui"export const Example = () => {  const colors = ["main", "neutral", "success", "warning", "critical"] as const  return (    <div className="flex flex-wrap items-center gap-4">      {colors.map((color) => (        <Pagination key={color} color={color} initialPage={1} total={10} />      ))}    </div>  )}

Variants

There is currently only a single light variant available.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  const variants = ["light"] as const  return (    <div className="flex flex-wrap items-center gap-4">      {variants.map((variant) => (        <Pagination          key={variant}          initialPage={1}          total={10}          variant={variant}        />      ))}    </div>  )}

Show controls

Set showControls to true to display prev/next arrow buttons at the edges.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return <Pagination showControls initialPage={1} total={10} />}

Pagination Loop

Set loop to true so the cursor wraps from the last page back to the first and vice versa.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return (    <Pagination loop showControls color="success" initialPage={1} total={5} />  )}

Initial page

Use initialPage to set the starting page for an uncontrolled pagination.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return <Pagination color="warning" initialPage={3} total={10} />}

Compact variant

Set isCompact to true for a denser layout with less item padding.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return <Pagination isCompact initialPage={3} total={100} />}

Controlled

Use page and onChange together to drive pagination from external state.

import { useState } from "react"import { Button, Pagination } from "@opengovsg/oui"export const Example = () => {  const [currentPage, setCurrentPage] = useState(1)  return (    <div className="flex flex-col gap-5">      <p className="text-small text-default-500">        Selected Page: {currentPage}      </p>      <Pagination page={currentPage} total={10} onChange={setCurrentPage} />      <div className="flex gap-2">        <Button          color="sub"          size="sm"          variant="outline"          onPress={() => setCurrentPage((prev) => (prev > 1 ? prev - 1 : prev))}        >          Previous        </Button>        <Button          color="sub"          size="sm"          variant="outline"          onPress={() =>            setCurrentPage((prev) => (prev < 10 ? prev + 1 : prev))          }        >          Next        </Button>      </div>    </div>  )}

Siblings

Use siblings to control how many page buttons appear on each side of the active page.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return (    <div className="flex flex-col gap-5">      <p>1 Sibling (default)</p>      <Pagination total={10} />      <p>2 Siblings</p>      <Pagination siblings={2} total={10} />      <p>3 Siblings</p>      <Pagination siblings={3} total={10} />    </div>  )}

Boundaries

Use boundaries to control how many page buttons appear at the start and end of the range.

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return (    <div className="flex flex-col gap-5">      <p>1 Boundary (default)</p>      <Pagination color="warning" total={10} />      <p>2 Boundaries</p>      <Pagination boundaries={2} color="warning" total={10} />      <p>3 Boundaries</p>      <Pagination boundaries={3} color="warning" total={10} />    </div>  )}

Custom items

Use renderItem to fully replace the default page button rendering. The function receives a PaginationItemRenderProps object (see render-prop state table in Props below).

import type { SVGProps } from "react"import type { PaginationProps } from "@opengovsg/oui"import { Pagination, PaginationItemType } from "@opengovsg/oui"import { cn } from "@opengovsg/oui-theme"const ChevronIcon = (props: SVGProps<SVGSVGElement>) => {  return (    <svg      aria-hidden="true"      fill="none"      focusable="false"      height="1em"      role="presentation"      viewBox="0 0 24 24"      width="1em"      {...props}    >      <path        d="M15.5 19l-7-7 7-7"        stroke="currentColor"        strokeLinecap="round"        strokeLinejoin="round"        strokeWidth="1.5"      />    </svg>  )}export const Example = () => {  const renderItem: PaginationProps["renderItem"] = ({    ref,    key,    value,    isActive,    onNext,    onPrevious,    setPage,    className,  }) => {    if (value === PaginationItemType.NEXT) {      return (        <button          key={key}          className={cn(className, "bg-default-200/50 h-8 w-8 min-w-8")}          onClick={onNext}        >          <ChevronIcon className="rotate-180" />        </button>      )    }    if (value === PaginationItemType.PREV) {      return (        <button          key={key}          className={cn(className, "bg-default-200/50 h-8 w-8 min-w-8")}          onClick={onPrevious}        >          <ChevronIcon />        </button>      )    }    if (value === PaginationItemType.DOTS) {      return (        <button key={key} className={className}>          ...        </button>      )    }    // cursor is the default item    return (      <button        key={key}        ref={ref}        className={cn(          className,          isActive &&            "bg-linear-to-br from-indigo-500 to-pink-500 font-bold text-white",        )}        onClick={() => setPage(value)}      >        {value}      </button>    )  }  return (    <Pagination      disableCursorAnimation      showControls      className="gap-2"      initialPage={1}      radius="full"      renderItem={renderItem}      total={10}      variant="light"    />  )}

Custom hook implementation

Use the exported usePagination hook to build a completely custom pagination UI while reusing OUI's range-calculation logic.

import type { SVGProps } from "react"import { PaginationItemType, usePagination } from "@opengovsg/oui"import { cn } from "@opengovsg/oui-theme"const ChevronIcon = (props: SVGProps<SVGSVGElement>) => {  return (    <svg      aria-hidden="true"      fill="none"      focusable="false"      height="1em"      role="presentation"      viewBox="0 0 24 24"      width="1em"      {...props}    >      <path        d="M15.5 19l-7-7 7-7"        stroke="currentColor"        strokeLinecap="round"        strokeLinejoin="round"        strokeWidth="1.5"      />    </svg>  )}export const Example = () => {  const { activePage, range, setPage, onNext, onPrevious } = usePagination({    total: 6,    showControls: true,    siblings: 10,    boundaries: 10,  })  return (    <div className="flex flex-col gap-2">      <p>Active page: {activePage}</p>      <ul className="flex items-center gap-2">        {range.map((page) => {          if (page === PaginationItemType.NEXT) {            return (              <li key={page} aria-label="next page" className="h-4 w-4">                <button                  className="h-full w-full rounded-full bg-gray-200"                  onClick={onNext}                >                  <ChevronIcon className="rotate-180" />                </button>              </li>            )          }          if (page === PaginationItemType.PREV) {            return (              <li key={page} aria-label="previous page" className="h-4 w-4">                <button                  className="h-full w-full rounded-full bg-gray-200"                  onClick={onPrevious}                >                  <ChevronIcon />                </button>              </li>            )          }          if (page === PaginationItemType.DOTS) {            return (              <li key={page} className="h-4 w-4">                ...              </li>            )          }          return (            <li key={page} aria-label={`page ${page}`} className="h-4 w-4">              <button                className={cn(                  "h-full w-full rounded-full bg-blue-300",                  activePage === page && "bg-gray-500",                )}                onClick={() => setPage(page)}              />            </li>          )        })}      </ul>    </div>  )}

Events

  • onChange — Called when the active page changes, receiving the new page number (page: number) => void. Use with page for controlled mode, or alone for uncontrolled.
  • onNext / onPrevious — Available on the PaginationItemRenderProps object passed to renderItem. Call these from custom prev/next buttons.
  • setPage — Also available in PaginationItemRenderProps. Programmatically jumps to any page number.

Accessibility

  • The root element renders as <nav role="navigation"> with aria-label="pagination navigation" (overridable via the aria-label prop).
  • Each page button receives an aria-label computed by getItemAriaLabel: "pagination item 3" for numbered pages, "previous page button", "next page button", "dots element", etc. Override all labels via the getItemAriaLabel prop.
  • All buttons are in tab order (tabindex="0") and follow standard focus-ring styles. Disabled pagination items get aria-disabled and data-disabled attributes.
  • Keyboard navigation uses standard button behavior: Tab / Shift+Tab moves between items, Enter or Space activates the focused item.
  • When multiple Pagination components appear on the same page, pass a unique descriptive aria-label to each so screen-reader users can distinguish them.

Slots

Slots are named regions of the component you can target with custom Tailwind classes via the classNames prop. Each slot below corresponds to a key on the classNames object.

  • base: The outer <nav> wrapper (handles horizontal scroll overflow).
  • wrapper: The inner flex row that contains all page items.
  • item: Each individual page-number button in the middle of the range.
  • prev: The previous-page control button (visible when showControls is true).
  • next: The next-page control button (visible when showControls is true).
  • cursor: The animated sliding highlight that sits behind the active page item.
  • forwardIcon: The forward-jump icon revealed on hover over an ellipsis button.
  • ellipsis: The "..." icon shown by default inside an ellipsis button.
  • chevronNext: The chevron icon inside the next-page control button.

Custom Styles

import { Pagination } from "@opengovsg/oui"export const Example = () => {  return (    <Pagination      classNames={{        wrapper: "gap-0 overflow-visible h-8 rounded-sm border border-gray-200",        item: "w-8 h-8 prose-caption-2 rounded-none bg-transparent",        cursor:          "bg-linear-to-b shadow-lg from-blue-500 to-blue-800 dark:from-blue-300 dark:to-blue-100 text-white font-bold",      }}      total={10}    />  )}

Props

Pagination

PropTypeDefaultDescription
totalnumber1The total number of pages
pagenumber-The controlled active page
initialPagenumber1The active page on initial render (uncontrolled)
onChange(page: number) => void-Called when the active page changes
siblingsnumber1Number of page buttons shown on each side of the active page
boundariesnumber1Number of page buttons shown at the start and end of the range
dotsJumpnumber5Pages added or subtracted when an ellipsis button is clicked
loopbooleanfalseWhether the pagination wraps from last page to first and vice versa
showControlsbooleanfalseWhether to show prev and next arrow buttons
isCompactbooleanfalseWhether to use a compact (less-padded) item layout
isDisabledbooleanfalseWhether all pagination items are disabled
renderItem(props: PaginationItemRenderProps) => ReactNode-Render function for a custom item; receives render-prop state (see table below)
getItemAriaLabel(page?: string | PaginationItemValue) => string-Override the default aria-label computed for each item
size"sm" | "md" | "lg""md"The size of each page button
color"main" | "neutral" | "critical" | "warning" | "success""neutral"The color of the animated cursor
radius"none" | "sm" | "md" | "lg" | "full""sm"The border-radius of page buttons and the cursor
variant"light""light"Visual variant (only "light" is available)
disableCursorAnimationbooleanfalseWhether to hide the sliding cursor animation
disableAnimationbooleanfalseWhether to disable all CSS transitions
classNamesSlotsToClasses<PaginationSlots>-Custom Tailwind classes keyed by slot name
classNamestring-Additional class added to the base slot

renderItem render-prop state (PaginationItemRenderProps)

When using renderItem, the function is called once per item in the computed range with the following object:

PropertyTypeDescription
valuenumber | "dots" | "prev" | "next"The item's logical value — a page number or a PaginationItemType sentinel
indexnumberZero-based position of this item in the rendered range array
pagenumberCalculated page position (includes dots positions)
totalnumberTotal number of pages
activePagenumberCurrently active page number
isActivebooleanWhether this item is the currently active page
isBeforebooleanWhether this item is positioned before the active page
isFirstbooleanWhether this item is the first in the range
isLastbooleanWhether this item is the last in the range
isNextbooleanWhether this item is the next-page control
isPreviousbooleanWhether this item is the prev-page control
dotsJumpnumberPages added/subtracted when this item (if it is dots) is activated
classNamestringPre-computed slot class string to apply to the rendered element
childrenReactNodeDefault content (the page number or icon)
onNext() => voidCallback to advance to the next page
onPrevious() => voidCallback to go to the previous page
setPage(page: number) => voidCallback to jump to a specific page
onPress(e: PressEvent) => void | undefinedPress handler for the item
getAriaLabel(page?: PaginationItemValue) => string | undefinedRetrieves the aria-label for the item

PaginationItem

PaginationItem is used internally by Pagination and is rarely needed directly. Use it when building fully custom pagination with usePagination.

PropTypeDefaultDescription
valuenumber | "dots" | "prev" | "next"-The logical value of this item
isActivebooleanfalseWhether this item is the currently active page
isDisabledbooleanfalseWhether this item is disabled
onPress(e: PressEvent) => void-Called when the item is pressed
getAriaLabel(page?: PaginationItemValue) => string | undefined-Function that returns the aria-label for this item

Accepts standard HTML <li> props (e.g., className, aria-label).

On this page