Pagination
Numbered pagination with ellipses, a sliding current-page marker and prev / next arrows.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/pagination.json'use client'
import { useState } from 'react'
import './pagination.css'
export interface PaginationProps {
total: number
page?: number
defaultPage?: number
onPageChange?: (p: number) => void
markerStyle?: 'fill' | 'ring'
fill?: 'dark' | 'light'
className?: string
}
export function pageSlots(page: number, total: number): (number | 'gap')[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
if (page <= 4) return [1, 2, 3, 4, 5, 'gap', total]
if (page >= total - 3) return [1, 'gap', total - 4, total - 3, total - 2, total - 1, total]
return [1, 'gap', page - 1, page, page + 1, 'gap', total]
}
/** Numbered pagination with a sliding current marker. */
export function Pagination({ total, page, defaultPage = 1, onPageChange, markerStyle = 'fill', fill = 'dark', className }: PaginationProps) {
const [inner, setInner] = useState(defaultPage)
const p = Math.min(total, Math.max(1, page ?? inner))
const go = (n: number) => { const c = Math.min(total, Math.max(1, n)); setInner(c); onPageChange?.(c) }
const slots = pageSlots(p, total)
const at = slots.indexOf(p)
const arrow = (dir: -1 | 1) => (
<button type="button" className="sd-pg__arr" aria-label={dir < 0 ? 'Previous page' : 'Next page'} disabled={dir < 0 ? p === 1 : p === total} onClick={() => go(p + dir)}>
<svg viewBox="0 0 16 16" aria-hidden><path d={dir < 0 ? 'M10 3.5 5.5 8l4.5 4.5' : 'M6 3.5 10.5 8 6 12.5'} /></svg>
</button>
)
return (
<nav aria-label="Pagination" className={['sd-pg', className].filter(Boolean).join(' ')} data-fill={fill} data-style={markerStyle}>
{arrow(-1)}
<div className="sd-pg__nums">
<span className="sd-pg__mark" aria-hidden style={{ transform: `translateX(${at * 36 - (slots.slice(0, at).filter((s) => s === 'gap').length * 12)}px)` }} />
{slots.map((s, i) =>
s === 'gap' ? <span key={`g${i}`} className="sd-pg__gap" aria-hidden>···</span> : (
<button key={s} type="button" className="sd-pg__n" aria-current={s === p ? 'page' : undefined} aria-label={`Page ${s}`} onClick={() => go(s)}>{s}</button>
),
)}
</div>
{arrow(1)}
</nav>
)
}.sd-pg { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-pg[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-pg button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-pg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-pg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-pg { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-pg__nums { position: relative; display: flex; gap: 2px; }
.sd-pg__mark { position: absolute; top: 0; left: 0; width: 34px; height: 34px; border-radius: 10px; background: var(--strong); transition: transform 320ms cubic-bezier(0.3, 1.3, 0.4, 1); }
.sd-pg[data-style='ring'] .sd-pg__mark { background: none; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-pg__n, .sd-pg__arr, .sd-pg__gap { position: relative; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; font-size: 13.5px; font-variant-numeric: tabular-nums; color: var(--mute); transition: color 200ms, background-color 200ms; }
.sd-pg__n:hover, .sd-pg__arr:hover:not(:disabled) { color: var(--fg); background: var(--soft); }
.sd-pg__n[aria-current='page'] { color: var(--on); background: none; font-weight: 600; }
.sd-pg[data-style='ring'] .sd-pg__n[aria-current='page'] { color: var(--fg); }
.sd-pg__arr:disabled { opacity: 0.3; cursor: default; }
.sd-pg__arr svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.sd-pg__gap { width: 22px; letter-spacing: 1px; }
@media (prefers-reduced-motion: reduce) { .sd-pg *, .sd-pg *::before, .sd-pg *::after { animation: none !important; transition: none !important; } }Overview
Shows the first, last and pages around the current one, collapsing the rest into ellipses so it never grows past seven slots and fits a phone.
The current marker slides between numbers instead of jumping, and the arrows dim at either end.
When to use
- Tables, search results and archives with a known page count.
When not to use
- Feeds: use infinite scroll or a Load more button.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/pagination.json$
npx shadcn@latest add https://sundaeui.dev/r/pagination.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/pagination.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/pagination.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy pagination.tsx and pagination.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { Pagination } from '@/components/sundae/pagination'
<Pagination />Props
| Prop | Type | Default |
|---|---|---|
total*Number of pages. | number | — |
page / defaultPageCurrent page (1-based). | number | 1 |
onPageChangeCalled on change. | (p: number) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A nav landmark labelled "Pagination"; the current page has aria-current="page".
- Arrow buttons have accessible names and are disabled at the ends.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-pg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Pagination free?
- Yes. Pagination is free for personal and commercial projects, with full source on this page.
- How do I install Pagination?
- Run npx shadcn@latest add https://sundaeui.dev/r/pagination.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Pagination support keyboard and screen readers?
- A nav landmark labelled "Pagination"; the current page has aria-current="page". Arrow buttons have accessible names and are disabled at the ends.
- Does Pagination respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →Underline Tabs
Navigation · RefinedBreadcrumbs
Navigation · Refined- Pro
File Tree
Navigation · Refined - Pro
Collapsible Sidebar
Navigation · Refined Vertical Tabs
Navigation · Refined- Pro
Nav Dropdown
Navigation · Refined