Mikkel Manniche
Dansk English Deutsch

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.json

With the registry added (components.json)

npx shadcn@latest add @manniche/pagination

Files

  • pagination.tsx
  • rolling-number.tsx