Navigation
Pagination
Previous and next with a page number that rolls digit by digit.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
pagination-demo.tsx
import { Pagination } from '@/components/pagination'
export default function PaginationDemo() {
return (
<div className="flex justify-center">
<Pagination total={24} defaultPage={8} onChange={(page) => console.log('load page', page)} />
</div>
)
}Source
pagination.tsx
// Based on Watermelon UI's “Pagination” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with a nav landmark and labels.
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useState } from 'react'
import { RollingNumber } from '@/registry/manniche/rolling-number/rolling-number'
import { cn } from '@/lib/utils'
export type PaginationProps = {
total: number
/** Controlled page, counted from 1. */
page?: number
defaultPage?: number
onChange?: (page: number) => void
/** The words, so the control speaks your language. */
labels?: { nav?: string; previous?: string; next?: string; of?: string }
className?: string
}
export function Pagination({ total, page, defaultPage = 1, onChange, labels = {}, className }: PaginationProps) {
const [own, setOwn] = useState(defaultPage)
const current = page ?? own
const { nav = 'Pages', previous = 'Previous page', next = 'Next page', of = 'of' } = labels
const go = (to: number) => {
const v = Math.min(total, Math.max(1, to))
if (v === current) return
setOwn(v)
onChange?.(v)
}
const btn =
'grid size-11 place-items-center rounded-xl bg-card text-foreground shadow-sm transition-[background-color,transform] duration-150 hover:bg-accent active:scale-[0.94] disabled:pointer-events-none disabled:opacity-40'
return (
<nav aria-label={nav} className={cn('inline-flex items-center gap-3 rounded-2xl border bg-muted p-1.5', className)}>
<button type="button" className={btn} onClick={() => go(current - 1)} disabled={current <= 1} aria-label={previous}>
<ArrowLeft className="size-5" aria-hidden />
</button>
<p className="flex items-baseline gap-1.5 text-base font-semibold text-muted-foreground" aria-live="polite">
<RollingNumber value={current} className="text-foreground" />
<span>
{of} {total}
</span>
</p>
<button type="button" className={btn} onClick={() => go(current + 1)} disabled={current >= total} aria-label={next}>
<ArrowRight className="size-5" aria-hidden />
</button>
</nav>
)
}rolling-number.tsx
// Based on the rolling digits in Watermelon UI's “Stepper” and “Pagination”
// (MIT, © 2026 Watermelon Platform Contributors, github.com/WatermelonCorp/watermelon-platform).
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useState } from 'react'
import { cn } from '@/lib/utils'
export type RollingNumberProps = {
value: number
/** Passed to Intl.NumberFormat, e.g. "da-DK". Leave it out for plain digits. */
locale?: string
className?: string
}
/** A number whose changed digits roll up or down. Screen readers get the whole number once. */
export function RollingNumber({ value, locale, className }: RollingNumberProps) {
// Remember the last value in state (not a ref), so the direction survives a double render.
const [last, setLast] = useState({ value, dir: 1 })
const dir = last.value === value ? last.dir : value > last.value ? 1 : -1
if (last.value !== value) setLast({ value, dir })
const text = locale ? new Intl.NumberFormat(locale).format(value) : String(value)
const chars = text.split('')
return (
<MotionConfig reducedMotion="user">
<span className={cn('inline-flex tabular-nums', className)}>
<span className="sr-only">{text}</span>
<span aria-hidden className="inline-flex">
{chars.map((ch, i) => {
// Count from the right, so the ones stay the ones when the number grows a digit.
const place = chars.length - i
return (
<span key={place} className="relative inline-flex min-w-[1ch] justify-center overflow-hidden">
<AnimatePresence mode="popLayout" initial={false} custom={dir}>
<motion.span
key={ch}
initial={{ y: `${dir * 70}%`, opacity: 0, filter: 'blur(2px)' }}
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
exit={{ y: `${dir * -70}%`, opacity: 0, filter: 'blur(2px)' }}
transition={{ type: 'spring', stiffness: 260, damping: 32 }}
className="inline-block"
>
{ch}
</motion.span>
</AnimatePresence>
</span>
)
})}
</span>
</span>
</MotionConfig>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/pagination.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/pagination