{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:component","title":"Pagination","description":"Page navigation with sibling and boundary ellipsis logic, a spring-gliding active pill, numbers that roll as the window shifts, arrow-nudge prev/next controls, and a compact dots variant.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/pagination.tsx","type":"registry:component","target":"@components/motion/pagination.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/pagination\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { useCallback, useId, useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { TOUCH_TARGET_CLASS, TOUCH_TARGET_Y_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PaginationVariant = \"numbers\" | \"dots\";\nexport type PaginationItem = number | \"ellipsis\";\n\nexport interface PaginationProps {\n  /** Total number of pages (1-based). */\n  total: number;\n  /** Controlled current page (1-based). */\n  page?: number;\n  /** Initial page for uncontrolled use. */\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\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  /** `numbers` renders page buttons; `dots` is a compact indicator row. */\n  variant?: PaginationVariant;\n  /** Most dots shown at once in the `dots` variant; the window follows the page. */\n  maxDots?: number;\n  /** Render previous / next controls. */\n  showControls?: boolean;\n  disabled?: boolean;\n  /** Accessible name for the `<nav>` landmark. */\n  \"aria-label\"?: string;\n  /** Accessible name per page button. */\n  getPageLabel?: (page: number) => string;\n  className?: string;\n}\n\nfunction range(start: number, end: number) {\n  const out: number[] = [];\n  for (let i = start; i <= end; i++) out.push(i);\n  return out;\n}\n\n/**\n * Page items with ellipses. The item count stays constant once ellipses\n * appear, so slots keep their position and only their numbers change.\n */\nexport function getPaginationItems(\n  total: number,\n  page: number,\n  siblings = 1,\n  boundaries = 1,\n): PaginationItem[] {\n  const count = Math.max(0, Math.floor(total));\n  const sib = Math.max(0, Math.floor(siblings));\n  const bound = Math.max(1, Math.floor(boundaries));\n  // boundaries + siblings + current + two ellipses\n  const slots = sib * 2 + 3 + bound * 2;\n  if (count <= slots) return range(1, count);\n\n  const left = Math.max(page - sib, bound + 1);\n  const right = Math.min(page + sib, count - bound);\n  const showLeft = left > bound + 2;\n  const showRight = right < count - bound - 1;\n\n  if (!showLeft && showRight) {\n    const leftCount = sib * 2 + bound + 2;\n    return [\n      ...range(1, leftCount),\n      \"ellipsis\",\n      ...range(count - bound + 1, count),\n    ];\n  }\n  if (showLeft && !showRight) {\n    const rightCount = sib * 2 + bound + 2;\n    return [\n      ...range(1, bound),\n      \"ellipsis\",\n      ...range(count - rightCount + 1, count),\n    ];\n  }\n  return [\n    ...range(1, bound),\n    \"ellipsis\",\n    ...range(left, right),\n    \"ellipsis\",\n    ...range(count - bound + 1, count),\n  ];\n}\n\nfunction clamp(value: number, total: number) {\n  return Math.min(Math.max(1, Math.round(value)), Math.max(1, total));\n}\n\n// Numbers roll in the direction of travel when the window shifts.\nconst ROLL_VARIANTS: Variants = {\n  initial: (dir: number) => ({ opacity: 0, y: dir >= 0 ? \"100%\" : \"-100%\" }),\n  animate: { opacity: 1, y: \"0%\", transition: SPRING_SWAP },\n  exit: (dir: number) => ({\n    opacity: 0,\n    y: dir >= 0 ? \"-100%\" : \"100%\",\n    transition: { duration: 0.14, ease: EASE_OUT },\n  }),\n};\n\nconst FADE_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\nconst ITEM_CLASS =\n  \"relative grid size-9 shrink-0 place-items-center rounded-full text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40\";\n\nexport function Pagination({\n  total,\n  page,\n  defaultPage = 1,\n  onPageChange,\n  siblings = 1,\n  boundaries = 1,\n  variant = \"numbers\",\n  maxDots = 7,\n  showControls = true,\n  disabled = false,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  getPageLabel = (p) => `Page ${p}`,\n  className,\n}: PaginationProps) {\n  const reduce = useReducedMotion() ?? false;\n  const canHover = useHoverCapable();\n  const groupId = useId();\n  const [internal, setInternal] = useState(() => clamp(defaultPage, total));\n  const controlled = page !== undefined;\n  const current = clamp(controlled ? page : internal, total);\n\n  // Direction of the last move drives the roll. Derived during render with\n  // the setState-in-render pattern so it is ready on the same pass.\n  const [track, setTrack] = useState({ page: current, dir: 0 });\n  if (track.page !== current) {\n    setTrack({ page: current, dir: current > track.page ? 1 : -1 });\n  }\n  const direction = track.page !== current\n    ? current > track.page ? 1 : -1\n    : track.dir;\n\n  const go = useCallback(\n    (next: number) => {\n      const target = clamp(next, total);\n      if (target === current || disabled) return;\n      if (!controlled) setInternal(target);\n      onPageChange?.(target);\n    },\n    [controlled, current, disabled, onPageChange, total],\n  );\n\n  const nudge = !reduce && canHover;\n  const rollVariants = reduce ? FADE_VARIANTS : ROLL_VARIANTS;\n  const pillTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  const control = (dir: -1 | 1) => {\n    const isPrev = dir === -1;\n    const Icon = isPrev ? ChevronLeft : ChevronRight;\n    const atEdge = isPrev ? current <= 1 : current >= total;\n    return (\n      <motion.button\n        type=\"button\"\n        aria-label={isPrev ? \"Previous page\" : \"Next page\"}\n        disabled={disabled || atEdge || total < 1}\n        onClick={() => go(current + dir)}\n        initial=\"rest\"\n        animate=\"rest\"\n        whileHover={nudge ? \"hover\" : undefined}\n        whileTap={reduce ? undefined : { scale: 0.9 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          ITEM_CLASS,\n          TOUCH_TARGET_CLASS,\n          \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n          variant === \"dots\" && \"size-8\",\n        )}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          variants={{ rest: { x: 0 }, hover: { x: isPrev ? -2 : 2 } }}\n          transition={SPRING_PRESS}\n          className=\"grid place-items-center\"\n        >\n          <Icon className=\"size-4\" />\n        </motion.span>\n      </motion.button>\n    );\n  };\n\n  if (variant === \"dots\") {\n    const size = Math.max(3, Math.min(maxDots, total));\n    const start = Math.min(\n      Math.max(1, current - Math.floor(size / 2)),\n      Math.max(1, total - size + 1),\n    );\n    const dots = range(start, Math.min(total, start + size - 1));\n    const first = dots[0] ?? 1;\n    const last = dots[dots.length - 1] ?? 1;\n\n    return (\n      <nav aria-label={ariaLabel} className={cn(\"inline-flex items-center gap-1\", className)}>\n        {showControls ? control(-1) : null}\n        <LayoutGroup id={groupId}>\n          <ul className=\"flex items-center gap-0.5\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {dots.map((p) => {\n                const active = p === current;\n                // Shrink edge dots when more pages hide beyond them.\n                const edge =\n                  (p === first && first > 1) || (p === last && last < total);\n                return (\n                  <motion.li\n                    key={p}\n                    layout={reduce ? false : \"position\"}\n                    initial={{ opacity: 0, scale: reduce ? 1 : 0.4 }}\n                    animate={{ opacity: 1, scale: edge && !reduce ? 0.6 : 1 }}\n                    exit={{ opacity: 0, scale: reduce ? 1 : 0.4 }}\n                    transition={reduce ? { duration: 0.12 } : SPRING_LAYOUT}\n                    className=\"flex\"\n                  >\n                    <button\n                      type=\"button\"\n                      aria-label={getPageLabel(p)}\n                      aria-current={active ? \"page\" : undefined}\n                      disabled={disabled}\n                      onClick={() => go(p)}\n                      className={cn(\n                        \"group relative grid h-5 w-5 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-40\",\n                        TOUCH_TARGET_Y_CLASS,\n                      )}\n                    >\n                      <span className=\"size-2 rounded-full bg-muted-foreground/35 transition-colors group-hover:bg-muted-foreground/60\" />\n                      {active ? (\n                        <motion.span\n                          layoutId=\"pagination-dot\"\n                          transition={pillTransition}\n                          className=\"absolute inset-x-0 top-1.5 h-2 rounded-full bg-foreground\"\n                        />\n                      ) : null}\n                    </button>\n                  </motion.li>\n                );\n              })}\n            </AnimatePresence>\n          </ul>\n        </LayoutGroup>\n        {showControls ? control(1) : null}\n      </nav>\n    );\n  }\n\n  const items = getPaginationItems(total, current, siblings, boundaries);\n\n  return (\n    <nav aria-label={ariaLabel} className={cn(\"inline-flex\", className)}>\n      <ul className=\"flex items-center gap-1\">\n        {showControls ? <li className=\"flex\">{control(-1)}</li> : null}\n        <LayoutGroup id={groupId}>\n          {items.map((item, index) => {\n            const active = item === current;\n            const itemKey = String(item);\n            return (\n              // Slots are keyed by position so buttons (and their focus)\n              // persist while the numbers inside roll to new values.\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional slots are the point.\n              <li key={index} className=\"relative flex\">\n                {active ? (\n                  <motion.span\n                    layoutId=\"pagination-pill\"\n                    transition={pillTransition}\n                    className=\"absolute inset-0 rounded-full bg-primary\"\n                  />\n                ) : null}\n                {item === \"ellipsis\" ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className={cn(ITEM_CLASS, \"text-muted-foreground\")}\n                  >\n                    …\n                  </span>\n                ) : (\n                  <motion.button\n                    type=\"button\"\n                    aria-label={getPageLabel(item)}\n                    aria-current={active ? \"page\" : undefined}\n                    disabled={disabled}\n                    onClick={() => go(item)}\n                    whileTap={reduce ? undefined : { scale: 0.9 }}\n                    transition={SPRING_PRESS}\n                    className={cn(\n                      ITEM_CLASS,\n                      TOUCH_TARGET_CLASS,\n                      active\n                        ? \"text-primary-foreground\"\n                        : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n                    )}\n                  >\n                    {/* The roll clips here rather than on the button, so the\n                        button's touch hit area can overhang it. */}\n                    <span className=\"relative grid size-full place-items-center overflow-hidden rounded-[inherit]\">\n                      <AnimatePresence\n                        initial={false}\n                        mode=\"popLayout\"\n                        custom={direction}\n                      >\n                        <motion.span\n                          key={itemKey}\n                          custom={direction}\n                          variants={rollVariants}\n                          initial=\"initial\"\n                          animate=\"animate\"\n                          exit=\"exit\"\n                          className=\"col-start-1 row-start-1\"\n                        >\n                          {item}\n                        </motion.span>\n                      </AnimatePresence>\n                    </span>\n                  </motion.button>\n                )}\n              </li>\n            );\n          })}\n        </LayoutGroup>\n        {showControls ? <li className=\"flex\">{control(1)}</li> : null}\n      </ul>\n    </nav>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}