{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:component","title":"Pagination","description":"Pages of a long list: the current-page mark is thrown to the new number, and in a narrow container it becomes \"3 of 12\" with a number that rolls.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/pagination.tsx","type":"registry:component","content":"// Hairline UI · pagination · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * PAGINATION: pages of a long list\n *\n *   move     the current-page mark is thrown to the new number\n *            (slight overshoot) instead of each number repainting\n *   window   first, last and the pages round the current one, with\n *            \"…\" between; the window keeps its width as you move\n *   narrow   in a small container it becomes ‹ \"3 of 12\" › and the\n *            numbers roll the way you moved\n * ───────────────────────────────────────────────────────── */\n\nexport interface PaginationProps {\n  page?: number;\n  defaultPage?: number;\n  pageCount: number;\n  onPageChange?: (page: number) => void;\n  /** Pages either side of the current one. */\n  siblings?: number;\n  /** Build hrefs so pages are real links (good for search engines). */\n  hrefFor?: (page: number) => string;\n  className?: string;\n}\n\ntype Slot = number | \"gap-start\" | \"gap-end\";\n\nfunction windowFor(page: number, count: number, siblings: number): Slot[] {\n  const total = siblings * 2 + 5;\n  if (count <= total) return Array.from({ length: count }, (_, i) => i + 1);\n  const left = Math.max(page - siblings, 2);\n  const right = Math.min(page + siblings, count - 1);\n  const showLeftGap = left > 3;\n  const showRightGap = right < count - 2;\n  if (!showLeftGap) return [...Array.from({ length: 3 + siblings * 2 }, (_, i) => i + 1), \"gap-end\", count];\n  if (!showRightGap) return [1, \"gap-start\", ...Array.from({ length: 3 + siblings * 2 }, (_, i) => count - (2 + siblings * 2) + i)];\n  return [1, \"gap-start\", ...Array.from({ length: right - left + 1 }, (_, i) => left + i), \"gap-end\", count];\n}\n\nfunction Arrow({ back }: { back?: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n      <path d={back ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport function Pagination({ page: controlled, defaultPage = 1, pageCount, onPageChange, siblings = 1, hrefFor, className = \"\" }: PaginationProps) {\n  const reduced = useInteractionReducedMotion();\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const [own, setOwn] = React.useState(defaultPage);\n  const page = Math.min(Math.max(1, controlled ?? own), Math.max(1, pageCount));\n  const [mark, setMark] = React.useState<{ x: number; w: number } | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const slots = windowFor(page, pageCount, siblings);\n\n  const go = (p: number) => {\n    const next = Math.min(Math.max(1, p), pageCount);\n    if (next === page) return;\n    if (controlled === undefined) setOwn(next);\n    onPageChange?.(next);\n  };\n\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const measure = () => {\n      const el = list.querySelector<HTMLElement>('[aria-current=\"page\"]');\n      if (!el || !el.offsetWidth) return setMark(null);\n      const next = { x: el.offsetLeft, w: el.offsetWidth };\n      setMark((p) => (p && p.x === next.x && p.w === next.w ? p : next));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(list);\n    return () => ro.disconnect();\n  }, [page, pageCount]);\n\n  React.useEffect(() => {\n    if (!mark || ready) return;\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, [mark, ready]);\n\n  const btn = `inline-flex size-8 shrink-0 items-center justify-center rounded-full text-[13px] tabular-nums cursor-pointer disabled:cursor-default disabled:opacity-35 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n  const linkProps = (p: number) =>\n    hrefFor\n      ? {\n          href: hrefFor(p),\n          onClick: (e: React.MouseEvent) => {\n            if (e.metaKey || e.ctrlKey || e.shiftKey) return;\n            e.preventDefault();\n            go(p);\n          },\n        }\n      : { onClick: () => go(p), type: \"button\" as const };\n  const Tag = (hrefFor ? \"a\" : \"button\") as \"button\";\n\n  return (\n    <nav aria-label=\"Pagination\" className={`@container w-full ${className}`}>\n      <div className=\"flex items-center justify-center gap-1\">\n        <button type=\"button\" aria-label=\"Previous page\" disabled={page <= 1} onClick={() => go(page - 1)} className={`${btn} text-muted-foreground hover:bg-accent hover:text-foreground`}>\n          <Arrow back />\n        </button>\n\n        {/* Wide: the numbers, with one mark that moves. */}\n        <div ref={listRef} className=\"relative hidden items-center gap-1 @[22rem]:flex\">\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-0 top-0 h-8 rounded-full bg-foreground\"\n            style={{\n              transform: `translateX(${mark?.x ?? 0}px)`,\n              width: mark?.w ?? 0,\n              opacity: mark ? 1 : 0,\n              transition: reduced || !ready ? \"none\" : `transform 420ms ${EASE_THROW}, width 300ms ${EASE_OUT}`,\n            }}\n          />\n          {slots.map((slot) =>\n            typeof slot === \"number\" ? (\n              <Tag\n                key={slot}\n                {...linkProps(slot)}\n                aria-label={`Page ${slot}`}\n                aria-current={slot === page ? \"page\" : undefined}\n                className={`${btn} relative z-10 min-w-8 px-1 transition-colors duration-300 ${slot === page ? \"text-background\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\"}`}\n              >\n                {slot}\n              </Tag>\n            ) : (\n              <span key={slot} aria-hidden=\"true\" className=\"inline-flex size-8 items-center justify-center text-[13px] text-muted-foreground\">\n                …\n              </span>\n            ),\n          )}\n        </div>\n\n        {/* Narrow: \"3 of 12\", rolling. */}\n        <span className=\"inline-flex items-center gap-1 px-2 text-[13px] tabular-nums text-muted-foreground @[22rem]:hidden\">\n          <span className=\"font-medium text-foreground\">\n            <NumberRoll value={page} />\n          </span>\n          of {pageCount}\n        </span>\n\n        <button type=\"button\" aria-label=\"Next page\" disabled={page >= pageCount} onClick={() => go(page + 1)} className={`${btn} text-muted-foreground hover:bg-accent hover:text-foreground`}>\n          <Arrow />\n        </button>\n      </div>\n    </nav>\n  );\n}\n\nexport default Pagination;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/pagination. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}