{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fluid-pagination",
  "type": "registry:ui",
  "title": "Fluid Pagination",
  "description": "Pagination with a sliding active indicator, smart ellipsis windowing and numbers that roll in the direction of travel.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/fluid-pagination.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport type PaginationToken = number | \"start-ellipsis\" | \"end-ellipsis\";\n\nexport interface PaginationRangeOptions {\n  /** Current page, 1-based. */\n  page: number;\n  totalPages: number;\n  /** Pages shown on each side of the current page. */\n  siblingCount?: number;\n  /** Pages always shown at the start and end. */\n  boundaryCount?: number;\n}\n\nconst range = (start: number, end: number) => (end < start ? [] : Array.from({ length: end - start + 1 }, (_, i) => start + i));\n\n/**\n * Pure helper: the page numbers and ellipses to render. The output length is constant for a given\n * total, sibling and boundary count, so slots never jump around as the current page moves.\n * An ellipsis that would hide a single page is replaced by that page.\n */\nexport function getPaginationRange({ page, totalPages, siblingCount = 1, boundaryCount = 1 }: PaginationRangeOptions): PaginationToken[] {\n  const total = Math.max(0, Math.floor(totalPages));\n  if (total === 0) return [];\n  const current = Math.min(Math.max(1, Math.floor(page)), total);\n  const siblings = Math.max(0, Math.floor(siblingCount));\n  const boundary = Math.max(0, Math.floor(boundaryCount));\n\n  const startPages = range(1, Math.min(boundary, total));\n  const endPages = range(Math.max(total - boundary + 1, boundary + 1), total);\n\n  const siblingsStart = Math.max(\n    Math.min(current - siblings, total - boundary - siblings * 2 - 1),\n    boundary + 2,\n  );\n  const siblingsEnd = Math.min(\n    Math.max(current + siblings, boundary + siblings * 2 + 2),\n    endPages.length > 0 ? endPages[0] - 2 : total - 1,\n  );\n\n  return [\n    ...startPages,\n    ...(siblingsStart > boundary + 2 ? ([\"start-ellipsis\"] as const) : boundary + 1 < total - boundary ? [boundary + 1] : []),\n    ...range(siblingsStart, siblingsEnd),\n    ...(siblingsEnd < total - boundary - 1\n      ? ([\"end-ellipsis\"] as const)\n      : total - boundary > boundary\n        ? [total - boundary]\n        : []),\n    ...endPages,\n  ];\n}\n\nexport interface FluidPaginationProps extends Omit<React.HTMLAttributes<HTMLElement>, \"onChange\"> {\n  /** Current page, 1-based (controlled). */\n  page?: number;\n  /** Initial page (uncontrolled). */\n  defaultPage?: number;\n  /** Called with the requested page. */\n  onPageChange?: (page: number) => void;\n  /** Number of pages. */\n  totalPages: number;\n  /** Pages shown on each side of the current page. */\n  siblingCount?: number;\n  /** Pages always shown at the start and end. */\n  boundaryCount?: number;\n  /** \"full\" page list, \"compact\" (Page 3 of 12 with arrows), or \"responsive\" (compact in containers narrower than 28rem). */\n  variant?: \"full\" | \"compact\" | \"responsive\";\n  /** Show previous / next buttons in the full variant. */\n  showPrevNext?: boolean;\n  /** Spring for the sliding active indicator. */\n  spring?: Spring;\n  /** Accent for the active page and focus rings. */\n  accent?: string;\n  /** Accessible name of the nav landmark. */\n  label?: string;\n  previousLabel?: string;\n  nextLabel?: string;\n}\n\nconst BTN =\n  \"relative inline-flex h-9 min-w-9 items-center justify-center rounded-[10px] text-sm font-medium tabular-nums tracking-tight transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fp-accent)] disabled:cursor-not-allowed disabled:opacity-35\";\nconst IDLE = \"text-zinc-600 hover:bg-zinc-900/[0.05] hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white\";\n\nfunction Chevron({ dir }: { dir: -1 | 1 }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"h-4 w-4\" aria-hidden=\"true\" fill=\"none\">\n      <path d={dir < 0 ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/** A number that rolls up or down when it changes. */\nfunction Roll({ value, direction, reduceMotion }: { value: React.ReactNode; direction: number; reduceMotion: boolean }) {\n  return (\n    <span className=\"relative inline-flex overflow-hidden\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.span\n          key={String(value)}\n          custom={direction}\n          variants={{\n            enter: (d: number) => ({ y: reduceMotion ? 0 : `${d * 100}%`, opacity: reduceMotion ? 1 : 0 }),\n            center: { y: \"0%\", opacity: 1 },\n            exit: (d: number) => ({ y: reduceMotion ? 0 : `${d * -100}%`, opacity: 0 }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={reduceMotion ? { duration: 0 } : { duration: 0.32, ease: [0.16, 1, 0.3, 1] }}\n          className=\"inline-block\"\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/**\n * Pagination with a sliding active indicator, windowed ellipses and numbers that roll in the\n * direction of travel. Renders a labelled nav landmark with aria-current=\"page\" on the current page.\n */\nexport function FluidPagination({\n  page,\n  defaultPage = 1,\n  onPageChange,\n  totalPages,\n  siblingCount = 1,\n  boundaryCount = 1,\n  variant = \"full\",\n  showPrevNext = true,\n  spring = { stiffness: 420, damping: 34, mass: 0.8 },\n  accent = \"#FF7448\",\n  label = \"Pagination\",\n  previousLabel = \"Previous page\",\n  nextLabel = \"Next page\",\n  className = \"\",\n  style,\n  ...props\n}: FluidPaginationProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const total = Math.max(1, Math.floor(totalPages));\n  const [internal, setInternal] = React.useState(defaultPage);\n  const current = Math.min(Math.max(1, page ?? internal), total);\n\n  // Direction of the last move (+1 forward, -1 back), derived when the page changes.\n  const [prev, setPrev] = React.useState(current);\n  const [direction, setDirection] = React.useState(1);\n  if (prev !== current) {\n    setPrev(current);\n    setDirection(current > prev ? 1 : -1);\n  }\n\n  const go = (next: number) => {\n    const clamped = Math.min(Math.max(1, next), total);\n    if (clamped === current) return;\n    if (page === undefined) setInternal(clamped);\n    onPageChange?.(clamped);\n  };\n\n  const tokens = getPaginationRange({ page: current, totalPages: total, siblingCount, boundaryCount });\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  const prevButton = (extra = \"\") => (\n    <button type=\"button\" aria-label={previousLabel} disabled={current <= 1} onClick={() => go(current - 1)} className={`${BTN} ${IDLE} ${extra}`}>\n      <Chevron dir={-1} />\n    </button>\n  );\n  const nextButton = (extra = \"\") => (\n    <button type=\"button\" aria-label={nextLabel} disabled={current >= total} onClick={() => go(current + 1)} className={`${BTN} ${IDLE} ${extra}`}>\n      <Chevron dir={1} />\n    </button>\n  );\n\n  const full = (\n    <ul className=\"flex flex-wrap items-center justify-center gap-1\">\n      {showPrevNext && <li>{prevButton()}</li>}\n      {tokens.map((token, slot) => {\n        // Slots are keyed by position, so when the window shifts the numbers roll in place.\n        if (typeof token !== \"number\") {\n          return (\n            <li key={`slot-${slot}`} aria-hidden=\"true\" className=\"inline-flex h-9 min-w-9 items-center justify-center font-mono text-xs tracking-[0.2em] text-zinc-400 dark:text-zinc-500\">\n              …\n            </li>\n          );\n        }\n        const active = token === current;\n        return (\n          <li key={`slot-${slot}`}>\n            <button\n              type=\"button\"\n              aria-label={`Page ${token}`}\n              aria-current={active ? \"page\" : undefined}\n              onClick={() => go(token)}\n              className={`${BTN} px-2 ${active ? \"text-zinc-950\" : IDLE}`}\n            >\n              {active && (\n                <motion.span\n                  aria-hidden=\"true\"\n                  layoutId={`${uid}-indicator`}\n                  transition={transition}\n                  className=\"absolute inset-0 rounded-[10px] bg-[var(--fp-accent)] shadow-[0_1px_2px_rgba(0,0,0,0.12)]\"\n                />\n              )}\n              <span className=\"relative\">\n                <Roll value={token} direction={direction} reduceMotion={reduceMotion} />\n              </span>\n            </button>\n          </li>\n        );\n      })}\n      {showPrevNext && <li>{nextButton()}</li>}\n    </ul>\n  );\n\n  const compact = (\n    <div className=\"flex items-center gap-2\">\n      {prevButton(\"border border-zinc-900/10 dark:border-white/10\")}\n      <p className=\"flex min-w-[8.5rem] items-baseline justify-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400\">\n        <span aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n          {`Page ${current} of ${total}`}\n        </span>\n        <span aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.14em]\">Page</span>\n        <span aria-hidden=\"true\" className=\"font-semibold tabular-nums text-zinc-950 dark:text-zinc-50\">\n          <Roll value={current} direction={direction} reduceMotion={reduceMotion} />\n        </span>\n        <span aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.14em]\">of</span>\n        <span aria-hidden=\"true\" className=\"font-medium tabular-nums text-zinc-700 dark:text-zinc-300\">{total}</span>\n      </p>\n      {nextButton(\"border border-zinc-900/10 dark:border-white/10\")}\n    </div>\n  );\n\n  return (\n    <nav\n      aria-label={label}\n      className={`${variant === \"responsive\" ? \"@container w-full\" : \"\"} ${className}`}\n      style={{ [\"--fp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {variant === \"full\" && full}\n      {variant === \"compact\" && compact}\n      {variant === \"responsive\" && (\n        <>\n          {/* Only one of these is displayed; display:none removes the other from the accessibility tree. */}\n          <div className=\"hidden justify-center @md:flex\">{full}</div>\n          <div className=\"flex justify-center @md:hidden\">{compact}</div>\n        </>\n      )}\n    </nav>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/fluid-pagination"
}
