{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pagination",
  "title": "Pagination",
  "description": "Numbered pages whose selected chip glides on a spring, with ellipses in a fixed number of slots so the width never changes, and a compact Page 3 of 12 variant.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-indicator.json",
    "https://eased.dev/r/eased-number-roll.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/pagination/pagination.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type KeyboardEvent,\n  type MouseEvent,\n  type ReactNode,\n} from \"react\";\nimport { Indicator, IndicatorGroup } from \"../_primitives/indicator/indicator\";\nimport { NumberRoll } from \"../_primitives/number-roll/number-roll\";\nimport { exitFast, instant, swap, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\nexport type PaginationProps = Omit<ComponentProps<\"nav\">, \"children\" | \"onChange\"> & {\n  /** How many pages there are. */\n  count: number;\n  /** The current page, from 1 (controlled). */\n  page?: number;\n  /** The starting page (uncontrolled). Default 1. */\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  /**\n   * `default`: numbered pages with ellipses. `compact`: previous, \"Page 3 of 12\" and next, for\n   * cards, tables and narrow screens. Default \"default\".\n   */\n  variant?: \"default\" | \"compact\";\n  /** `sm` is 32px (tables, cards); `md` is 36px (pages). Default \"md\". */\n  size?: \"sm\" | \"md\";\n  /** Pages shown either side of the current one. Default 1. */\n  siblings?: number;\n  /** Pages always shown at each end. Default 1. */\n  boundaries?: number;\n  /**\n   * Render real links: `getHref={(p) => \"?page=\" + p}`. Each link still calls `onPageChange`, so\n   * a client router can take over. Leave it out for buttons.\n   */\n  getHref?: (page: number) => string;\n  /** The page buttons' accessible names. Default `Page 3`. */\n  getPageLabel?: (page: number) => string;\n  disabled?: boolean;\n  /** The landmark's name. Default \"Pagination\". */\n  \"aria-label\"?: string;\n  className?: string;\n};\n\ntype Slot = number | \"ellipsis\";\n\n/**\n * The visible slots. Once the pages outnumber them, the slot count is fixed (boundaries, an\n * ellipsis or a page, the siblings and current, an ellipsis or a page, boundaries), so the\n * control never changes width while you page through it.\n */\nexport function paginationSlots(page: number, count: number, siblings = 1, boundaries = 1): Slot[] {\n  const range = (from: number, to: number) => Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i);\n  if (count <= boundaries * 2 + siblings * 2 + 3) return range(1, count);\n  const start = range(1, boundaries);\n  const end = range(count - boundaries + 1, count);\n  const from = Math.max(Math.min(page - siblings, count - boundaries - siblings * 2 - 1), boundaries + 2);\n  const to = Math.min(Math.max(page + siblings, boundaries + siblings * 2 + 2), count - boundaries - 1);\n  return [\n    ...start,\n    from > boundaries + 2 ? \"ellipsis\" : boundaries + 1,\n    ...range(from, to),\n    to < count - boundaries - 1 ? \"ellipsis\" : count - boundaries,\n    ...end,\n  ];\n}\n\nconst SIZES = {\n  sm: { item: \"h-control-sm min-w-control-sm rounded-control-sm px-1.5 text-ui-sm\", icon: \"size-control-sm rounded-control-sm\", gap: \"gap-1\" },\n  md: { item: \"h-control-md min-w-control-md rounded-control px-2 text-ui\", icon: \"size-control-md rounded-control\", gap: \"gap-1\" },\n} as const;\n\nconst COLOUR_ONLY =\n  \"[transition:color_150ms_var(--eased-ease-standard),background-color_150ms_var(--eased-ease-standard),box-shadow_var(--eased-duration-focus)_var(--eased-ease-standard)] focus-ring\";\n\nconst Chevron = ({ dir }: { dir: -1 | 1 }) => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n    {/* Shifted 1px toward its direction (R57). */}\n    <path d={dir < 0 ? \"M9.5 4 5.5 8l4 4\" : \"M6.5 4l4 4-4 4\"} />\n  </svg>\n);\n\ntype PageControlProps = {\n  href?: string;\n  label: string;\n  current: boolean;\n  inert: boolean;\n  number: boolean;\n  className: string;\n  onSelect: () => void;\n  children: ReactNode;\n};\n\n/** One button or link in the row. Numbers press by colour only: the chip measures them, so they never scale (R88). */\nfunction PageControl({ href, label, current, inert, number, className, onSelect, children }: PageControlProps) {\n  const props = {\n    \"data-pagination-item\": \"\",\n    \"aria-label\": label,\n    \"aria-current\": current ? (\"page\" as const) : undefined,\n    \"aria-disabled\": inert || undefined,\n    onClick: (e: MouseEvent) => {\n      if (inert) return e.preventDefault();\n      onSelect();\n    },\n    className: `relative inline-flex shrink-0 items-center justify-center font-medium tabular-nums whitespace-nowrap select-none ${number ? COLOUR_ONLY : pressIcon} ${\n      inert\n        ? \"cursor-not-allowed text-text-disabled active:scale-none\"\n        : current\n          ? \"cursor-default text-text\"\n          : \"cursor-pointer text-text-muted hover-fine:bg-surface-hover hover-fine:text-text active:bg-surface-active\"\n    } ${className}`,\n  };\n  return href ? (\n    <a href={href} {...props}>\n      {children}\n    </a>\n  ) : (\n    <button type=\"button\" {...props}>\n      {children}\n    </button>\n  );\n}\n\n/**\n * Pages with a selected chip that glides between them on the snappy spring. Past seven pages\n * the middle collapses behind ellipses into a fixed number of slots, so the control keeps its\n * width; when the window shifts, the numbers in a slot slide over in the direction of travel\n * while the chip stays put. Previous and next stay focusable at the ends (`aria-disabled`), so\n * focus is never dropped.\n */\nexport function Pagination({\n  count,\n  page: controlled,\n  defaultPage = 1,\n  onPageChange,\n  variant = \"default\",\n  size = \"md\",\n  siblings = 1,\n  boundaries = 1,\n  getHref,\n  getPageLabel = (p) => `Page ${p}`,\n  disabled = false,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  className = \"\",\n  ...rest\n}: PaginationProps) {\n  const total = Math.max(1, Math.floor(count));\n  const [own, setOwn] = useState(defaultPage);\n  const page = Math.min(Math.max(1, controlled ?? own), total);\n  const reduced = useSafeReducedMotion();\n  const s = SIZES[size];\n\n  // Which way the numbers travel when a slot's page changes.\n  const [prev, setPrev] = useState(page);\n  const [dir, setDir] = useState<1 | -1>(1);\n  if (prev !== page) {\n    setDir(page > prev ? 1 : -1);\n    setPrev(page);\n  }\n\n  // A clicked number can move to another slot; send focus after it so it never lands on a\n  // slot that now reads a different page.\n  const listRef = useRef<HTMLUListElement>(null);\n  const [follow, setFollow] = useState(0);\n  useEffect(() => {\n    if (follow) listRef.current?.querySelector<HTMLElement>('[aria-current=\"page\"]')?.focus();\n  }, [follow]);\n\n  const go = (next: number, fromNumber = false) => {\n    if (disabled) return;\n    const target = Math.min(Math.max(1, next), total);\n    if (target === page) return;\n    if (fromNumber) setFollow((f) => f + 1);\n    if (controlled === undefined) setOwn(target);\n    onPageChange?.(target);\n  };\n\n  // Left and right move focus along the row; Tab still visits every control.\n  const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {\n    if (e.key !== \"ArrowLeft\" && e.key !== \"ArrowRight\") return;\n    const items = [...(listRef.current?.querySelectorAll<HTMLElement>(\"[data-pagination-item]\") ?? [])];\n    const i = items.indexOf(document.activeElement as HTMLElement);\n    if (i < 0) return;\n    e.preventDefault();\n    items[Math.min(Math.max(0, i + (e.key === \"ArrowRight\" ? 1 : -1)), items.length - 1)]?.focus();\n  };\n\n  const control = (o: { target: number; label: string; current?: boolean; off?: boolean; number?: boolean; className: string; children: ReactNode }) => {\n    const inert = disabled || !!o.off;\n    return (\n      <PageControl\n        href={getHref && !inert ? getHref(o.target) : undefined}\n        label={o.label}\n        current={!!o.current}\n        inert={inert}\n        number={!!o.number}\n        className={o.className}\n        onSelect={() => go(o.target, !!o.number)}\n      >\n        {o.children}\n      </PageControl>\n    );\n  };\n\n  const prevButton = control({\n    target: page - 1,\n    label: \"Previous page\",\n    off: page <= 1,\n    className: s.icon,\n    children: <Chevron dir={-1} />,\n  });\n  const nextButton = control({\n    target: page + 1,\n    label: \"Next page\",\n    off: page >= total,\n    className: s.icon,\n    children: <Chevron dir={1} />,\n  });\n\n  if (variant === \"compact\") {\n    return (\n      <nav aria-label={ariaLabel} {...rest} className={`inline-flex ${className}`}>\n        <ul ref={listRef} onKeyDown={onKeyDown} className={`flex flex-1 items-center justify-between ${s.gap}`}>\n          <li className=\"flex\">{prevButton}</li>\n          <li className={`px-2 whitespace-nowrap tabular-nums text-text-muted ${size === \"sm\" ? \"text-ui-sm\" : \"text-ui\"}`}>\n            {/* Read once, on change, from a region that stays mounted. */}\n            <span aria-live=\"polite\" aria-atomic className=\"sr-only\">{`Page ${page} of ${total}`}</span>\n            <span aria-hidden className=\"inline-flex items-baseline gap-1\">\n              Page\n              <span className=\"font-medium text-text\">\n                <NumberRoll value={page} animated={!reduced} aria-hidden />\n              </span>\n              of <span className=\"font-medium text-text\">{total}</span>\n            </span>\n          </li>\n          <li className=\"flex\">{nextButton}</li>\n        </ul>\n      </nav>\n    );\n  }\n\n  const slots = paginationSlots(page, total, siblings, boundaries);\n  const enterX = reduced ? 0 : dir * 8;\n\n  return (\n    <nav aria-label={ariaLabel} {...rest} className={`inline-flex max-w-full ${className}`}>\n      <IndicatorGroup>\n        <ul ref={listRef} onKeyDown={onKeyDown} className={`flex items-center ${s.gap}`}>\n          <li className=\"flex\">{prevButton}</li>\n          {slots.map((slot, i) => (\n            <li key={i} className=\"flex\">\n              {slot === \"ellipsis\" ? (\n                <span aria-hidden className={`inline-flex items-center justify-center text-text-muted select-none ${s.item}`}>\n                  …\n                </span>\n              ) : (\n                control({\n                  target: slot,\n                  label: getPageLabel(slot),\n                  current: slot === page,\n                  number: true,\n                  className: s.item,\n                  children: (\n                    <>\n                      {slot === page && <Indicator className=\"inset-0 rounded-[inherit] bg-surface-active\" />}\n                      {/* The slot's number slides over when the window shifts beneath the chip. */}\n                      <span className=\"relative grid overflow-hidden\">\n                        <AnimatePresence initial={false} mode=\"popLayout\">\n                          <motion.span\n                            key={slot}\n                            className=\"col-start-1 row-start-1\"\n                            initial={reduced ? { opacity: 0 } : { opacity: 0, x: enterX, filter: \"blur(2px)\" }}\n                            animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                            exit={reduced ? { opacity: 0, transition: instant } : { opacity: 0, x: -enterX * 0.75, filter: \"blur(2px)\", transition: exitFast }}\n                            transition={reduced ? instant : swap}\n                          >\n                            {slot}\n                          </motion.span>\n                        </AnimatePresence>\n                      </span>\n                    </>\n                  ),\n                })\n              )}\n            </li>\n          ))}\n          <li className=\"flex\">{nextButton}</li>\n        </ul>\n      </IndicatorGroup>\n    </nav>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/pagination/pagination.tsx"
    }
  ],
  "meta": {
    "industries": [
      "saas",
      "finance",
      "ecommerce",
      "legal",
      "hr"
    ]
  },
  "docs": "Import Pagination from components/eased/pagination/pagination. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}