{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:ui","title":"Pagination","description":"Pure page-window engine plus numbered, compact, card-footer and carousel-dot paginators; the volt marker glides to the active page and numbers slide as the window shifts.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/pagination.tsx","type":"registry:ui","target":"components/ui/pagination.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowLeft, ArrowRight, ChevronLeft, ChevronRight, MoreHorizontal } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ engine */\n\nexport type PageItem = { type: \"page\"; page: number } | { type: \"ellipsis\"; position: \"start\" | \"end\" };\n\nexport interface GetPageItemsOptions {\n  /** Current page, 1-based. Clamped into range. */\n  page: number;\n  /** Total number of pages. */\n  count: number;\n  /** Pages shown on each side of the current page. */\n  siblings?: number;\n  /** Pages always shown at the start and end. */\n  boundaries?: number;\n}\n\nconst range = (from: number, to: number) => (to < from ? [] : Array.from({ length: to - from + 1 }, (_, i) => from + i));\n\n/**\n * Pure page-window engine. Returns a stable-length list (once ellipses kick in) of pages and ellipses:\n * boundaries at both ends, `siblings` around the current page, and an ellipsis only where it hides 2+ pages.\n */\nexport function getPageItems({ page, count, siblings = 1, boundaries = 1 }: GetPageItemsOptions): PageItem[] {\n  const total = Math.max(0, Math.floor(count));\n  if (total === 0) return [];\n  const sib = Math.max(0, Math.floor(siblings));\n  const bnd = Math.max(0, Math.floor(boundaries));\n  const current = Math.min(Math.max(1, Math.floor(page)), total);\n\n  // boundaries ×2 + siblings ×2 + current + two ellipsis slots\n  const slots = bnd * 2 + sib * 2 + 3;\n  if (total <= slots) return range(1, total).map((p) => ({ type: \"page\", page: p }));\n\n  const start = range(1, bnd);\n  const end = range(total - bnd + 1, total);\n  // Keep the sibling window the same width when it presses against either end.\n  const winStart = Math.max(Math.min(current - sib, total - bnd - sib * 2 - 1), bnd + 2);\n  const winEnd = Math.min(Math.max(current + sib, bnd + sib * 2 + 2), total - bnd - 1);\n\n  const items: PageItem[] = start.map((p) => ({ type: \"page\", page: p }));\n  if (winStart > bnd + 2) items.push({ type: \"ellipsis\", position: \"start\" });\n  else if (bnd + 1 < total - bnd) items.push({ type: \"page\", page: bnd + 1 });\n  for (const p of range(winStart, winEnd)) items.push({ type: \"page\", page: p });\n  if (winEnd < total - bnd - 1) items.push({ type: \"ellipsis\", position: \"end\" });\n  else if (total - bnd > bnd) items.push({ type: \"page\", page: total - bnd });\n  for (const p of end) items.push({ type: \"page\", page: p });\n  return items;\n}\n\n/* ------------------------------------------------------------------ shared */\n\nfunction usePage(count: number, page?: number, defaultPage = 1, onPageChange?: (page: number) => void) {\n  const [inner, setInner] = React.useState(defaultPage);\n  const raw = page ?? inner;\n  const value = Math.min(Math.max(1, raw), Math.max(1, count));\n  const set = React.useCallback(\n    (next: number) => {\n      const clamped = Math.min(Math.max(1, next), Math.max(1, count));\n      if (page === undefined) setInner(clamped);\n      onPageChange?.(clamped);\n    },\n    [count, page, onPageChange],\n  );\n  return [value, set] as const;\n}\n\ninterface PageStateProps {\n  /** Total number of pages. */\n  count: number;\n  /** Current page (1-based), controlled. */\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n}\n\nconst sizes = {\n  sm: { item: \"h-8 min-w-8 text-[13px]\", icon: \"size-8\", glyph: \"size-3.5\" },\n  md: { item: \"h-9 min-w-9 text-sm\", icon: \"size-9\", glyph: \"size-4\" },\n  lg: { item: \"h-10 min-w-10 text-[15px]\", icon: \"size-10\", glyph: \"size-4\" },\n} as const;\n\nconst focus = \"outline-none focus-visible:shadow-[var(--focus-ring)]\";\nconst arrowBtn =\n  \"inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full text-ink-muted transition-colors duration-150 hover:bg-surface-hover hover:text-ink disabled:pointer-events-none disabled:opacity-40\";\n\n/* ------------------------------------------------------------------ numbered */\n\nexport interface PaginationProps extends PageStateProps, Omit<React.HTMLAttributes<HTMLElement>, \"onChange\"> {\n  siblings?: number;\n  boundaries?: number;\n  size?: \"sm\" | \"md\" | \"lg\";\n  shape?: \"round\" | \"square\";\n  /** Show \"Previous\" / \"Next\" text beside the arrows (from the sm breakpoint up). */\n  showLabels?: boolean;\n  /** Below the sm breakpoint, swap the numbers for \"Page x of y\". */\n  responsive?: boolean;\n  /** Accessible name of the nav landmark. */\n  label?: string;\n}\n\n/** Numbered pagination with prev/next, smart ellipses and a volt marker that glides to the active page. */\nexport function Pagination({\n  count,\n  page: pageProp,\n  defaultPage,\n  onPageChange,\n  siblings = 1,\n  boundaries = 1,\n  size = \"md\",\n  shape = \"round\",\n  showLabels = true,\n  responsive = true,\n  label = \"Pagination\",\n  className,\n  ...props\n}: PaginationProps) {\n  const reduce = useReducedMotion();\n  const groupId = React.useId();\n  const [page, setPage] = usePage(count, pageProp, defaultPage, onPageChange);\n  const items = getPageItems({ page, count, siblings, boundaries });\n  const s = sizes[size];\n  const radius = shape === \"round\" ? \"rounded-full\" : \"rounded-sm\";\n  const spring = reduce ? { duration: 0 } : ({ type: \"spring\", stiffness: 460, damping: 34 } as const);\n\n  return (\n    <nav aria-label={label} className={cn(\"flex w-full items-center justify-between gap-2\", className)} {...props}>\n      <button\n        type=\"button\"\n        onClick={() => setPage(page - 1)}\n        disabled={page <= 1}\n        aria-label=\"Go to previous page\"\n        className={cn(arrowBtn, focus, s.item, showLabels ? \"px-2.5 sm:pr-3.5\" : \"\", radius)}\n      >\n        <ArrowLeft aria-hidden className={s.glyph} />\n        {showLabels && <span className=\"hidden font-medium sm:inline\">Previous</span>}\n      </button>\n\n      <LayoutGroup id={groupId}>\n        <ul className={cn(\"flex items-center gap-1\", responsive && \"hidden sm:flex\")}>\n          {items.map((item) => (\n            <motion.li key={item.type === \"page\" ? `p${item.page}` : `e-${item.position}`} layout=\"position\" transition={spring}>\n              {item.type === \"ellipsis\" ? (\n                <span aria-hidden className={cn(\"inline-flex items-center justify-center text-ink-subtle\", s.item)}>\n                  <MoreHorizontal className={s.glyph} />\n                </span>\n              ) : (\n                <button\n                  type=\"button\"\n                  onClick={() => setPage(item.page)}\n                  aria-current={item.page === page ? \"page\" : undefined}\n                  aria-label={`Page ${item.page}`}\n                  className={cn(\n                    \"relative inline-flex items-center justify-center px-2 font-mono tabular-nums transition-colors duration-150\",\n                    focus,\n                    s.item,\n                    radius,\n                    item.page === page ? \"font-semibold text-on-volt\" : \"text-ink-muted hover:bg-surface-hover hover:text-ink\",\n                  )}\n                >\n                  {item.page === page && (\n                    <motion.span layoutId=\"page-marker\" aria-hidden className={cn(\"absolute inset-0 -z-0 bg-volt\", radius)} transition={spring} />\n                  )}\n                  <span className=\"relative\">{item.page}</span>\n                </button>\n              )}\n            </motion.li>\n          ))}\n        </ul>\n      </LayoutGroup>\n\n      {responsive && (\n        <p className=\"text-sm text-ink-muted sm:hidden\" aria-live=\"polite\">\n          Page <span className=\"font-mono font-semibold text-ink\">{page}</span> of <span className=\"font-mono\">{count}</span>\n        </p>\n      )}\n\n      <button\n        type=\"button\"\n        onClick={() => setPage(page + 1)}\n        disabled={page >= count}\n        aria-label=\"Go to next page\"\n        className={cn(arrowBtn, focus, s.item, showLabels ? \"px-2.5 sm:pl-3.5\" : \"\", radius)}\n      >\n        {showLabels && <span className=\"hidden font-medium sm:inline\">Next</span>}\n        <ArrowRight aria-hidden className={s.glyph} />\n      </button>\n    </nav>\n  );\n}\n\n/* ------------------------------------------------------------------ compact */\n\nexport interface PaginationCompactProps extends PageStateProps, Omit<React.HTMLAttributes<HTMLElement>, \"onChange\"> {\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Let people type a page number. */\n  editable?: boolean;\n  label?: string;\n}\n\n/** \"Page 3 of 12\" between two arrow buttons; the number blur-swaps, and can be an editable field. */\nexport function PaginationCompact({ count, page: pageProp, defaultPage, onPageChange, size = \"md\", editable, label = \"Pagination\", className, ...props }: PaginationCompactProps) {\n  const reduce = useReducedMotion();\n  const [page, setPage] = usePage(count, pageProp, defaultPage, onPageChange);\n  const [draft, setDraft] = React.useState<string | null>(null);\n  const s = sizes[size];\n  const [dir, setDir] = React.useState(1);\n\n  const go = (n: number) => {\n    setDir(n > page ? 1 : -1);\n    setPage(n);\n  };\n  const commit = () => {\n    if (draft === null) return;\n    const n = parseInt(draft, 10);\n    if (!Number.isNaN(n)) go(n);\n    setDraft(null);\n  };\n\n  return (\n    <nav aria-label={label} className={cn(\"inline-flex items-center gap-1\", className)} {...props}>\n      <button type=\"button\" onClick={() => go(page - 1)} disabled={page <= 1} aria-label=\"Go to previous page\" className={cn(arrowBtn, focus, s.icon)}>\n        <ChevronLeft aria-hidden className={s.glyph} />\n      </button>\n      <span className={cn(\"inline-flex items-center gap-1.5 px-1.5 text-ink-muted\", s.item)}>\n        Page\n        {editable ? (\n          <input\n            inputMode=\"numeric\"\n            aria-label={`Page number, 1 to ${count}`}\n            value={draft ?? String(page)}\n            onChange={(e) => setDraft(e.target.value.replace(/\\D/g, \"\"))}\n            onBlur={commit}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") commit();\n              if (e.key === \"Escape\") setDraft(null);\n              if (e.key === \"ArrowUp\") {\n                e.preventDefault();\n                go(page + 1);\n              }\n              if (e.key === \"ArrowDown\") {\n                e.preventDefault();\n                go(page - 1);\n              }\n            }}\n            style={{ width: `calc(${Math.max(String(count).length, (draft ?? String(page)).length)}ch + 18px)` }}\n            className={cn(\n              \"h-[80%] rounded-xs border border-border-strong bg-surface-sunken text-center font-mono font-semibold text-ink tabular-nums\",\n              \"outline-none focus-visible:border-volt-ink focus-visible:shadow-[var(--focus-ring)]\",\n            )}\n          />\n        ) : (\n          <span className=\"relative inline-flex overflow-hidden font-mono font-semibold tabular-nums text-ink\" aria-live=\"polite\">\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={dir}>\n              <motion.span\n                key={page}\n                custom={dir}\n                variants={{\n                  enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * 10, filter: \"blur(4px)\" }),\n                  center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n                  exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: d * -10, filter: \"blur(4px)\" }),\n                }}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n              >\n                {page}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        )}\n        of <span className=\"font-mono tabular-nums\">{count}</span>\n      </span>\n      <button type=\"button\" onClick={() => go(page + 1)} disabled={page >= count} aria-label=\"Go to next page\" className={cn(arrowBtn, focus, s.icon)}>\n        <ChevronRight aria-hidden className={s.glyph} />\n      </button>\n    </nav>\n  );\n}\n\n/* ------------------------------------------------------------------ card footer */\n\nexport interface PaginationFooterProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Total number of records. */\n  total: number;\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  pageSize?: number;\n  defaultPageSize?: number;\n  onPageSizeChange?: (size: number) => void;\n  pageSizeOptions?: number[];\n  /** Noun for the records, plural. */\n  noun?: string;\n}\n\n/** Table/card footer: \"Showing 21–30 of 284\", a rows-per-page select and compact page arrows. */\nexport function PaginationFooter({\n  total,\n  page: pageProp,\n  defaultPage,\n  onPageChange,\n  pageSize: pageSizeProp,\n  defaultPageSize = 10,\n  onPageSizeChange,\n  pageSizeOptions = [10, 25, 50, 100],\n  noun = \"results\",\n  className,\n  ...props\n}: PaginationFooterProps) {\n  const selectId = React.useId();\n  const [innerSize, setInnerSize] = React.useState(defaultPageSize);\n  const pageSize = pageSizeProp ?? innerSize;\n  const count = Math.max(1, Math.ceil(total / pageSize));\n  const [page, setPage] = usePage(count, pageProp, defaultPage, onPageChange);\n  const from = total === 0 ? 0 : (page - 1) * pageSize + 1;\n  const to = Math.min(total, page * pageSize);\n  const fmt = (n: number) => n.toLocaleString(\"en-US\");\n\n  return (\n    <div className={cn(\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-border px-4 py-3\", className)} {...props}>\n      <p className=\"text-[13px] text-ink-muted\" aria-live=\"polite\">\n        Showing <span className=\"font-mono font-medium text-ink tabular-nums\">{fmt(from)}–{fmt(to)}</span> of{\" \"}\n        <span className=\"font-mono tabular-nums\">{fmt(total)}</span> {noun}\n      </p>\n      <div className=\"flex items-center gap-3\">\n        <label htmlFor={selectId} className=\"flex items-center gap-2 text-[13px] text-ink-muted\">\n          <span className=\"hidden sm:inline\">Rows per page</span>\n          <span className=\"sm:hidden\">Rows</span>\n          <select\n            id={selectId}\n            value={pageSize}\n            onChange={(e) => {\n              const next = Number(e.target.value);\n              if (pageSizeProp === undefined) setInnerSize(next);\n              onPageSizeChange?.(next);\n              // Keep the first visible record on screen.\n              setPage(Math.floor(((page - 1) * pageSize) / next) + 1);\n            }}\n            className={cn(\"h-8 rounded-xs border border-border-strong bg-surface-sunken px-2 font-mono text-[13px] text-ink\", focus)}\n          >\n            {pageSizeOptions.map((o) => (\n              <option key={o} value={o}>\n                {o}\n              </option>\n            ))}\n          </select>\n        </label>\n        <PaginationCompact count={count} page={page} onPageChange={setPage} size=\"sm\" label={`${noun} pages`} />\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ dots */\n\nexport interface PaginationDotsProps extends PageStateProps, Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  size?: \"sm\" | \"md\";\n  /** \"volt\" for product surfaces, \"contrast\" for dots over imagery. */\n  tone?: \"volt\" | \"contrast\";\n  /** Sit the dots in a frosted pill (for imagery). */\n  framed?: boolean;\n  /** Accessible name; each dot reads \"Slide n of N\". */\n  label?: string;\n  itemLabel?: (index: number, count: number) => string;\n}\n\n/** Carousel dots: the active one stretches into a capsule that glides between positions. */\nexport function PaginationDots({\n  count,\n  page: pageProp,\n  defaultPage,\n  onPageChange,\n  size = \"md\",\n  tone = \"volt\",\n  framed,\n  label = \"Slides\",\n  itemLabel = (i, n) => `Slide ${i} of ${n}`,\n  className,\n  ...props\n}: PaginationDotsProps) {\n  const reduce = useReducedMotion();\n  const groupId = React.useId();\n  const [page, setPage] = usePage(count, pageProp, defaultPage, onPageChange);\n  const dot = size === \"sm\" ? \"h-1.5\" : \"h-2\";\n  const spring = reduce ? { duration: 0 } : ({ type: \"spring\", stiffness: 420, damping: 32 } as const);\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={cn(\"inline-flex items-center\", framed && \"rounded-full bg-glass px-2 py-0.5 shadow-sm backdrop-blur-md\", className)}\n      onKeyDown={(e) => {\n        if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n        e.preventDefault();\n        const next = e.key === \"ArrowRight\" ? Math.min(count, page + 1) : Math.max(1, page - 1);\n        setPage(next);\n        refs.current[next - 1]?.focus();\n      }}\n      {...props}\n    >\n      <LayoutGroup id={groupId}>\n        {Array.from({ length: count }, (_, i) => {\n          const n = i + 1;\n          const active = n === page;\n          return (\n            <motion.button\n              key={n}\n              ref={(el) => {\n                refs.current[i] = el;\n              }}\n              type=\"button\"\n              layout\n              transition={spring}\n              onClick={() => setPage(n)}\n              aria-label={itemLabel(n, count)}\n              aria-current={active ? \"true\" : undefined}\n              tabIndex={active ? 0 : -1}\n              className={cn(\"group/dot relative flex h-6 items-center justify-center rounded-full\", focus, active ? (size === \"sm\" ? \"w-7\" : \"w-9\") : \"w-4\")}\n            >\n              <span\n                aria-hidden\n                className={cn(\n                  \"w-full max-w-2 rounded-full transition-colors duration-150\",\n                  dot,\n                  size === \"sm\" && \"max-w-1.5\",\n                  tone === \"volt\" ? \"bg-border-strong group-hover/dot:bg-ink-subtle\" : \"bg-ink/35 group-hover/dot:bg-ink/60\",\n                  active && \"opacity-0\",\n                )}\n              />\n              {active && (\n                <motion.span\n                  layoutId=\"dot-marker\"\n                  aria-hidden\n                  transition={spring}\n                  className={cn(\"absolute inset-x-1.5 rounded-full\", dot, tone === \"volt\" ? \"bg-volt\" : \"bg-ink\")}\n                />\n              )}\n            </motion.button>\n          );\n        })}\n      </LayoutGroup>\n    </div>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","pagination","pager","pages","table","carousel","dots","navigation"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/pagination"}}