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.jsonpnpm dlx shadcn@latest add https://oui.open.gov.sg/r/pagination.jsonnpx shadcn@latest add https://oui.open.gov.sg/r/pagination.jsonbunx --bun shadcn@latest add https://oui.open.gov.sg/r/pagination.jsonPagination 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
disableCursorAnimationis 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 withpagefor controlled mode, or alone for uncontrolled.onNext/onPrevious— Available on thePaginationItemRenderPropsobject passed torenderItem. Call these from custom prev/next buttons.setPage— Also available inPaginationItemRenderProps. Programmatically jumps to any page number.
Accessibility
- The root element renders as
<nav role="navigation">witharia-label="pagination navigation"(overridable via thearia-labelprop). - Each page button receives an
aria-labelcomputed bygetItemAriaLabel:"pagination item 3"for numbered pages,"previous page button","next page button","dots element", etc. Override all labels via thegetItemAriaLabelprop. - All buttons are in tab order (
tabindex="0") and follow standard focus-ring styles. Disabled pagination items getaria-disabledanddata-disabledattributes. - 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-labelto 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 whenshowControlsis true).next: The next-page control button (visible whenshowControlsis 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
| Prop | Type | Default | Description |
|---|---|---|---|
total | number | 1 | The total number of pages |
page | number | - | The controlled active page |
initialPage | number | 1 | The active page on initial render (uncontrolled) |
onChange | (page: number) => void | - | Called when the active page changes |
siblings | number | 1 | Number of page buttons shown on each side of the active page |
boundaries | number | 1 | Number of page buttons shown at the start and end of the range |
dotsJump | number | 5 | Pages added or subtracted when an ellipsis button is clicked |
loop | boolean | false | Whether the pagination wraps from last page to first and vice versa |
showControls | boolean | false | Whether to show prev and next arrow buttons |
isCompact | boolean | false | Whether to use a compact (less-padded) item layout |
isDisabled | boolean | false | Whether 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) |
disableCursorAnimation | boolean | false | Whether to hide the sliding cursor animation |
disableAnimation | boolean | false | Whether to disable all CSS transitions |
classNames | SlotsToClasses<PaginationSlots> | - | Custom Tailwind classes keyed by slot name |
className | string | - | 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:
| Property | Type | Description |
|---|---|---|
value | number | "dots" | "prev" | "next" | The item's logical value — a page number or a PaginationItemType sentinel |
index | number | Zero-based position of this item in the rendered range array |
page | number | Calculated page position (includes dots positions) |
total | number | Total number of pages |
activePage | number | Currently active page number |
isActive | boolean | Whether this item is the currently active page |
isBefore | boolean | Whether this item is positioned before the active page |
isFirst | boolean | Whether this item is the first in the range |
isLast | boolean | Whether this item is the last in the range |
isNext | boolean | Whether this item is the next-page control |
isPrevious | boolean | Whether this item is the prev-page control |
dotsJump | number | Pages added/subtracted when this item (if it is dots) is activated |
className | string | Pre-computed slot class string to apply to the rendered element |
children | ReactNode | Default content (the page number or icon) |
onNext | () => void | Callback to advance to the next page |
onPrevious | () => void | Callback to go to the previous page |
setPage | (page: number) => void | Callback to jump to a specific page |
onPress | (e: PressEvent) => void | undefined | Press handler for the item |
getAriaLabel | (page?: PaginationItemValue) => string | undefined | Retrieves 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.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | "dots" | "prev" | "next" | - | The logical value of this item |
isActive | boolean | false | Whether this item is the currently active page |
isDisabled | boolean | false | Whether 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).