Mikkel Manniche
Dansk English Deutsch

Navigation

Pagination

Forrige og næste med et sidetal, der ruller ciffer for ciffer.

Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.

Forhåndsvisning

Brug

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>
  )
}

Kilde

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ér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/pagination.json

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/pagination

Filer

  • pagination.tsx
  • rolling-number.tsx