{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination","type":"registry:component","title":"Animated Pagination","description":"Page navigation with a gliding current-page pill, page numbers that slide as the window moves, and ellipses that jump several pages.","author":"Saurabh <saurabh10102@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// beui.dev/components/motion/pagination\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentPropsWithRef,\n  type ReactElement,\n  type ReactNode,\n  useId,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { PresenceGate, type PresenceGateRenderProps } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\n\ntype PaginationItem = number | \"ellipsis-start\" | \"ellipsis-end\";\n\n/**\n * The pages to show: `boundaries` pages at each end, `siblings` either side of\n * the current page, and an ellipsis for every gap of two or more pages. The\n * result keeps one length while the current page moves, so the row never\n * changes width as you page through.\n */\nexport function paginationRange(\n  page: number,\n  totalPages: number,\n  siblings = 1,\n  boundaries = 1,\n): PaginationItem[] {\n  const range = (start: number, end: number) =>\n    Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index);\n  // Boundaries, siblings, the current page and two ellipses.\n  const slots = boundaries * 2 + siblings * 2 + 3;\n  if (totalPages <= slots) return range(1, totalPages);\n\n  const left = Math.max(page - siblings, boundaries);\n  const right = Math.min(page + siblings, totalPages - boundaries);\n  const showStart = left > boundaries + 2;\n  const showEnd = right < totalPages - (boundaries + 1);\n\n  if (!showStart && showEnd) {\n    return [\n      ...range(1, siblings * 2 + boundaries + 2),\n      \"ellipsis-end\",\n      ...range(totalPages - boundaries + 1, totalPages),\n    ];\n  }\n  if (showStart && !showEnd) {\n    return [\n      ...range(1, boundaries),\n      \"ellipsis-start\",\n      ...range(totalPages - (boundaries + 1 + siblings * 2), totalPages),\n    ];\n  }\n  return [\n    ...range(1, boundaries),\n    \"ellipsis-start\",\n    ...range(left, right),\n    \"ellipsis-end\",\n    ...range(totalPages - boundaries + 1, totalPages),\n  ];\n}\n\nexport interface PaginationProps\n  extends Omit<ComponentPropsWithRef<\"nav\">, \"onChange\" | \"children\"> {\n  totalPages: number;\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  /** Pages shown either side of the current one. */\n  siblings?: number;\n  /** Pages always shown at each end. */\n  boundaries?: number;\n  /** Pages an ellipsis jumps when pressed. */\n  jump?: number;\n  /**\n   * Render pages as links. Each control still calls `onPageChange`, so a\n   * client router can update state while the href keeps the page shareable.\n   */\n  getHref?: (page: number) => string;\n  /** Render your router's Link, spreading these props onto it. */\n  renderLink?: (props: ComponentPropsWithRef<\"a\">) => ReactElement;\n}\n\nconst CONTROL =\n  \"relative inline-flex h-8 min-w-8 items-center justify-center rounded-full px-2 text-sm tabular-nums sm:h-9 sm:min-w-9 outline-none transition-[color,background-color,transform] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.94] motion-reduce:active:scale-100\";\n\nexport function Pagination({\n  totalPages,\n  page,\n  defaultPage = 1,\n  onPageChange,\n  siblings = 1,\n  boundaries = 1,\n  jump = 5,\n  getHref,\n  renderLink,\n  className,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  ...props\n}: PaginationProps) {\n  const id = useId();\n  const reduce = useReducedMotion();\n  const total = Math.max(1, Math.floor(totalPages));\n  const clamp = (value: number) => Math.min(total, Math.max(1, Math.round(value)));\n  const [internal, setInternal] = useState(() => clamp(defaultPage));\n  const current = clamp(page ?? internal);\n\n  const go = (next: number) => {\n    const target = clamp(next);\n    if (target === current) return;\n    if (page === undefined) setInternal(target);\n    onPageChange?.(target);\n  };\n\n  // One control: a link when hrefs are given, otherwise a button.\n  const control = (\n    target: number,\n    {\n      label,\n      current: isCurrent = false,\n      disabled = false,\n      className: controlClass,\n      children,\n      gate,\n    }: {\n      label: string;\n      current?: boolean;\n      disabled?: boolean;\n      className?: string;\n      children: ReactNode;\n      /** Releases a control the moment it starts leaving. */\n      gate?: PresenceGateRenderProps[\"gate\"];\n    },\n  ) => {\n    const shared = {\n      ...gate,\n      \"aria-label\": label,\n      \"aria-current\": isCurrent ? (\"page\" as const) : undefined,\n      className: cn(CONTROL, controlClass),\n      children,\n    };\n    // A disabled control is always a button: a link cannot be disabled.\n    if (getHref && !disabled) {\n      const linkProps: ComponentPropsWithRef<\"a\"> = {\n        ...shared,\n        href: getHref(target),\n        onClick: (event) => {\n          if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;\n          go(target);\n        },\n      };\n      return renderLink ? renderLink(linkProps) : <a {...linkProps} />;\n    }\n    return (\n      <button type=\"button\" disabled={disabled} onClick={() => go(target)} {...shared} />\n    );\n  };\n\n  const items = paginationRange(current, total, siblings, boundaries);\n  const fade = { duration: reduce ? 0.12 : 0.16, ease: EASE_OUT };\n\n  return (\n    <nav {...props} aria-label={ariaLabel} className={cn(\"flex\", className)}>\n      <LayoutGroup id={id}>\n        <ul className=\"relative flex items-center gap-0.5 sm:gap-1\">\n          <li>\n            {control(current - 1, {\n              label: \"Previous page\",\n              disabled: current === 1,\n              className:\n                \"text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:opacity-40\",\n              children: <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />,\n            })}\n          </li>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {items.map((item) => {\n              if (typeof item !== \"number\") {\n                const back = item === \"ellipsis-start\";\n                const Chevrons = back ? ChevronsLeft : ChevronsRight;\n                return (\n                  <motion.li\n                    key={item}\n                    layout={reduce ? false : \"position\"}\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1 }}\n                    exit={{ opacity: 0, transition: fade }}\n                    transition={{ layout: SPRING_LAYOUT, opacity: fade }}\n                  >\n                    <PresenceGate>\n                    {({ gate }) => control(current + (back ? -jump : jump), {\n                      gate,\n                      label: back ? `Back ${jump} pages` : `Forward ${jump} pages`,\n                      className: \"group text-muted-foreground hover:text-foreground\",\n                      children: (\n                        <>\n                          {/* Hover or focus turns the dots into the jump's direction. */}\n                          <span\n                            aria-hidden=\"true\"\n                            className=\"transition-[opacity,transform] duration-150 ease-out group-hover:scale-75 group-hover:opacity-0 group-focus-visible:scale-75 group-focus-visible:opacity-0 motion-reduce:transform-none\"\n                          >\n                            …\n                          </span>\n                          <Chevrons\n                            aria-hidden=\"true\"\n                            className=\"absolute size-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-out group-hover:scale-100 group-hover:opacity-100 group-focus-visible:scale-100 group-focus-visible:opacity-100 motion-reduce:transform-none\"\n                          />\n                        </>\n                      ),\n                    })}\n                    </PresenceGate>\n                  </motion.li>\n                );\n              }\n              const isCurrent = item === current;\n              return (\n                <motion.li\n                  key={item}\n                  layout={reduce ? false : \"position\"}\n                  initial={{ opacity: 0, scale: reduce ? 1 : 0.8 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: reduce ? 1 : 0.8, transition: fade }}\n                  transition={{ layout: SPRING_LAYOUT, opacity: fade, scale: SPRING_LAYOUT }}\n                >\n                  {/* An exiting number keeps its layout spring after it has\n                      faded; the gate stops it catching clicks or focus. */}\n                  <PresenceGate>\n                  {({ gate }) => control(item, {\n                    gate,\n                    label: `Page ${item}`,\n                    current: isCurrent,\n                    className: isCurrent\n                      ? \"font-medium text-background\"\n                      : \"text-muted-foreground hover:bg-foreground/[0.05] hover:text-foreground\",\n                    children: (\n                      <>\n                        {isCurrent ? (\n                          <motion.span\n                            aria-hidden=\"true\"\n                            layoutId={reduce ? undefined : \"pagination-current\"}\n                            transition={SPRING_LAYOUT}\n                            className=\"absolute inset-0 rounded-full bg-foreground\"\n                          />\n                        ) : null}\n                        <span className=\"relative\">{item}</span>\n                      </>\n                    ),\n                  })}\n                  </PresenceGate>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n          <li>\n            {control(current + 1, {\n              label: \"Next page\",\n              disabled: current === total,\n              className:\n                \"text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:opacity-40\",\n              children: <ChevronRight aria-hidden=\"true\" className=\"size-4\" />,\n            })}\n          </li>\n        </ul>\n      </LayoutGroup>\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/presence-gate.tsx","type":"registry:lib","target":"@lib/presence-gate.tsx","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\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"}]}