{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pagination",
  "type": "registry:ui",
  "title": "Pagination",
  "description": "Move through a long collection with clear bounds.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/pagination/pagination.tsx",
      "type": "registry:component",
      "target": "@components/xzero/pagination/pagination.tsx",
      "content": "\"use client\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { ChevronLeft as NavArrowLeft, ChevronRight as NavArrowRight } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./pagination.module.css\";\nexport interface PaginationProps { page: number; pageCount: number; onPageChange: (page: number) => void; label?: string }\n/** When the five page window shifts, numbers travel like a belt: each one moves by the same number of slots. */\nconst slot = 1000 / 9; // One button plus the gap (36px + 4px), as a percentage of the button width.\nconst slide: Variants = {\n  enter: (shift: number) => ({ opacity: 0, x: `${slot * shift}%` }),\n  center: { opacity: 1, x: \"0%\" },\n  exit: (shift: number) => ({ opacity: 0, x: `${-slot * shift}%`, transition: { x: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } } }),\n};\nexport function Pagination({ page, pageCount, onPageChange, label = \"Pagination\" }: PaginationProps) {\n  const reduced = useReducedMotion() ?? false;\n  const navRef = useRef<HTMLElement>(null);\n  const markX = useMotionValue(0);\n  const markY = useMotionValue(0);\n  const safePageCount = Math.max(0, Math.floor(pageCount));\n  const currentPage = safePageCount > 0 ? Math.min(Math.max(1, Math.floor(page)), safePageCount) : 0;\n  const start = safePageCount > 0 ? Math.max(1, Math.min(safePageCount - 4, currentPage - 2)) : 1;\n  const [previousStart, setPreviousStart] = useState(start);\n  const [shift, setShift] = useState(0);\n  if (previousStart !== start) { setShift(start - previousStart); setPreviousStart(start); }\n  const visible = Array.from({ length: Math.min(safePageCount, 5) }, (_, index) => start + index);\n\n  // One mark travels to the current page's final slot. Until it has measured once (and during server render),\n  // the current button draws its own border so nothing flashes before hydration.\n  useLayoutEffect(() => {\n    const nav = navRef.current;\n    const current = nav?.querySelector<HTMLElement>(\"[aria-current='page']\");\n    if (!nav || !current) return;\n    const place = (instant: boolean) => {\n      const x = current.offsetLeft, y = current.offsetTop;\n      if (instant || reduced) { markX.jump(x); markY.jump(y); return; }\n      animate(markX, x, motionTokens.spring.morph);\n      animate(markY, y, motionTokens.spring.morph);\n    };\n    place(!(\"markReady\" in nav.dataset));\n    nav.dataset.markReady = \"\";\n    let size = `${nav.offsetWidth}x${nav.offsetHeight}`;\n    const observer = new ResizeObserver(() => { const next = `${nav.offsetWidth}x${nav.offsetHeight}`; if (next !== size) { size = next; place(true); } });\n    observer.observe(nav);\n    return () => observer.disconnect();\n  }, [currentPage, start, safePageCount, reduced, markX, markY]);\n\n  if (!safePageCount) return <nav className={styles.nav} aria-label={label} />;\n  return <nav ref={navRef} className={styles.nav} aria-label={label}><button type=\"button\" className={styles.step} onClick={() => onPageChange(currentPage - 1)} disabled={currentPage <= 1} aria-label=\"Previous page\"><NavArrowLeft width={16} height={16} aria-hidden=\"true\" /></button>\n    <motion.span aria-hidden=\"true\" className={styles.mark} style={{ x: markX, y: markY }} />\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={shift}>\n      {visible.map(number => <motion.button type=\"button\" key={number} onClick={() => onPageChange(number)} aria-label={`Page ${number}`} aria-current={currentPage === number ? \"page\" : undefined}\n        layout={reduced ? false : \"position\"} layoutDependency={start} custom={shift} variants={slide} initial={reduced ? false : \"enter\"} animate=\"center\" exit={reduced ? undefined : \"exit\"}\n        transition={reduced ? { duration: 0 } : { x: motionTokens.spring.smooth, layout: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }}>{number}</motion.button>)}\n    </AnimatePresence>\n    <button type=\"button\" className={styles.step} onClick={() => onPageChange(currentPage + 1)} disabled={currentPage >= safePageCount} aria-label=\"Next page\"><NavArrowRight width={16} height={16} aria-hidden=\"true\" /></button></nav>;\n}\n"
    },
    {
      "path": "registry/components/pagination/pagination.module.css",
      "type": "registry:component",
      "target": "@components/xzero/pagination/pagination.module.css",
      "content": ".nav { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }\n.nav button { position: relative; display: grid; width: var(--control-height-sm); height: var(--control-height-sm); place-items: center; border: 1px solid transparent; border-radius: var(--radius-control); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n.nav button[aria-current=\"page\"] { color: var(--foreground); }\n/* One shared mark travels between pages. Before it has measured (server render), the button draws its own. */\n.mark { position: absolute; top: 0; left: 0; width: var(--control-height-sm); height: var(--control-height-sm); box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); box-shadow: var(--shadow-resting); opacity: 0; pointer-events: none; }\n.nav[data-mark-ready] .mark { opacity: 1; }\n.nav:not([data-mark-ready]) button[aria-current=\"page\"] { border-color: var(--border); background: var(--surface); box-shadow: var(--shadow-resting); }\n/* Only the arrows press in. Page numbers move with Motion, so they keep CSS off transform and opacity. */\n.nav .step { transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.nav .step:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), 100ms; transition-timing-function: var(--ease-standard), var(--ease-standard), var(--ease-standard), ease-out; }\n.nav button:disabled { opacity: .4; cursor: not-allowed; }\n@media (hover: hover) and (pointer: fine) {\n  .nav button:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }\n  .nav[data-mark-ready] button[aria-current=\"page\"]:hover { background: transparent; }\n}\n@media (prefers-reduced-motion: reduce) { .nav button, .nav .step { transition: none; } .nav .step:active:not(:disabled) { transform: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/pagination",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "navigation"
    ],
    "docs": "https://ui.xzero.co.uk/components/pagination",
    "markdown": "https://ui.xzero.co.uk/components/pagination/markdown"
  }
}