{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pagination",
  "title": "Pagination",
  "description": "Moves through a long list in steady chunks, with the window of pages always the same width so controls never shift.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/pagination.tsx",
      "content": "import type * as React from \"react\"\nimport { useId, useState } from \"react\"\nimport { LayoutGroup, useReducedMotion } from \"motion/react\"\nimport {\n  DotsView,\n  LoadMoreView,\n  NumbersView,\n  SimpleView,\n  type PageViewProps,\n} from \"@/registry/miseui/ui/pagination-views\"\nimport { getDotWindow, getPageWindow, type PageSlot } from \"@/registry/miseui/ui/pagination-window\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype PaginationVariant = \"numbers\" | \"simple\" | \"dots\" | \"load-more\"\ntype PaginationEffect = \"spring\"\n\nconst DEFAULT_PAGE_SIZE = 24\n\ninterface PaginationProps extends Omit<React.ComponentProps<\"nav\">, \"onChange\"> {\n  /** `numbers` a page window, `simple` previous and next around \"Page 3 of 12\", `dots` for carousels, `load-more` a button with a running count. */\n  variant?: PaginationVariant\n  /** `spring` slides the active page indicator between pages. */\n  effect?: PaginationEffect\n  page?: number\n  defaultPage?: number\n  onPageChange?: (page: number) => void\n  /** Number of pages. Derived from `itemCount` and `pageSize` when left out. */\n  pageCount?: number\n  /** `numbers`: pages either side of the current one. Use 0 in tight spaces. */\n  siblings?: number\n  boundaries?: number\n  /** `load-more`: total items, for \"Showing 24 of 60\". */\n  itemCount?: number\n  pageSize?: number\n  /** `load-more`: the next page is on its way. */\n  loading?: boolean\n}\n\ninterface BodyProps extends PageViewProps {\n  variant: PaginationVariant\n  siblings?: number\n  boundaries?: number\n  itemCount: number\n  pageSize: number\n  loading: boolean\n}\n\nfunction PaginationBody({ variant, siblings, boundaries, itemCount, pageSize, loading, ...view }: BodyProps) {\n  if (variant === \"simple\") return <SimpleView {...view} />\n  if (variant === \"dots\") return <DotsView {...view} />\n  if (variant === \"load-more\") {\n    return (\n      <LoadMoreView\n        page={view.page}\n        pageCount={view.pageCount}\n        goTo={view.goTo}\n        itemCount={itemCount}\n        pageSize={pageSize}\n        loading={loading}\n      />\n    )\n  }\n  return <NumbersView {...view} siblings={siblings} boundaries={boundaries} />\n}\n\nfunction usePageState(props: Pick<PaginationProps, \"page\" | \"defaultPage\" | \"onPageChange\">, pageCount: number) {\n  const [uncontrolled, setUncontrolled] = useState(props.defaultPage ?? 1)\n  const page = Math.min(Math.max(pageCount, 1), Math.max(1, props.page ?? uncontrolled))\n  const goTo = (next: number) => {\n    setUncontrolled(next)\n    props.onPageChange?.(next)\n  }\n  return { page, goTo }\n}\n\nfunction Pagination({\n  variant = \"numbers\",\n  effect,\n  page: pageProp,\n  defaultPage,\n  onPageChange,\n  pageCount: pageCountProp,\n  siblings,\n  boundaries,\n  itemCount,\n  pageSize = DEFAULT_PAGE_SIZE,\n  loading = false,\n  className,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  ...props\n}: PaginationProps) {\n  const groupId = useId()\n  const reduceMotion = useReducedMotion() ?? false\n  const pageCount = Math.max(0, pageCountProp ?? Math.ceil((itemCount ?? 0) / Math.max(1, pageSize)))\n  const { page, goTo } = usePageState({ page: pageProp, defaultPage, onPageChange }, pageCount)\n  const body = (\n    <PaginationBody\n      variant={variant}\n      page={page}\n      pageCount={pageCount}\n      goTo={goTo}\n      indicator={effect === \"spring\" && !reduceMotion ? \"slide\" : \"static\"}\n      siblings={siblings}\n      boundaries={boundaries}\n      itemCount={itemCount ?? pageCount * pageSize}\n      pageSize={pageSize}\n      loading={loading}\n    />\n  )\n  return (\n    <nav\n      aria-label={ariaLabel}\n      data-slot=\"pagination\"\n      data-variant={variant}\n      data-effect={effect}\n      className={cn(\"flex justify-center\", className)}\n      {...props}\n    >\n      {/* layoutId is global to the page; without a per-instance group two paginations would share one indicator. */}\n      {effect === \"spring\" ? <LayoutGroup id={groupId}>{body}</LayoutGroup> : body}\n    </nav>\n  )\n}\n\nexport {\n  Pagination,\n  getPageWindow,\n  getDotWindow,\n  type PageSlot,\n  type PaginationProps,\n  type PaginationVariant,\n  type PaginationEffect,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/pagination-views.tsx",
      "content": "import { motion } from \"motion/react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { getDotWindow, getPageWindow, type PageSlot } from \"@/registry/miseui/ui/pagination-window\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst CONTROL_CLASS = [\n  \"relative inline-flex shrink-0 items-center justify-center rounded-md text-sm font-medium tabular-nums outline-none\",\n  \"transition-[color,background-color,scale] duration-(--duration-fast) ease-(--ease-mise) active:scale-[0.97] active:duration-100\",\n  \"focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:active:scale-100\",\n].join(\" \")\n/** 40 px under a finger, tighter where there is a mouse. */\nconst SQUARE_CLASS = \"size-10 pointer-fine:size-9\"\n\nexport interface PageViewProps {\n  page: number\n  pageCount: number\n  goTo: (page: number) => void\n  /** `slide` travels between pages through a shared layoutId; `static` just appears on the active one. */\n  indicator: \"slide\" | \"static\"\n}\n\nfunction ActiveFill({ indicator, className }: { indicator: PageViewProps[\"indicator\"]; className: string }) {\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      data-slot=\"pagination-indicator\"\n      layoutId={indicator === \"slide\" ? \"pagination-indicator\" : undefined}\n      transition={SPRING_DEFAULT}\n      className={cn(\"absolute\", className)}\n    />\n  )\n}\n\nfunction StepButton({ direction, page, pageCount, goTo }: { direction: \"previous\" | \"next\" } & PageViewProps) {\n  const isNext = direction === \"next\"\n  const Icon = isNext ? ChevronRight : ChevronLeft\n  return (\n    <button\n      type=\"button\"\n      aria-label={isNext ? \"Next page\" : \"Previous page\"}\n      disabled={isNext ? page >= pageCount : page <= 1}\n      onClick={() => goTo(isNext ? page + 1 : page - 1)}\n      className={cn(CONTROL_CLASS, SQUARE_CLASS, \"text-foreground hover:bg-accent\")}\n    >\n      <Icon aria-hidden=\"true\" className=\"size-4\" />\n    </button>\n  )\n}\n\nfunction NumberCell({ slot, page, goTo, indicator }: { slot: PageSlot } & Omit<PageViewProps, \"pageCount\">) {\n  if (typeof slot !== \"number\") {\n    return (\n      <li\n        aria-hidden=\"true\"\n        className={cn(\"inline-flex items-center justify-center text-sm text-muted-foreground\", SQUARE_CLASS)}\n      >\n        …\n      </li>\n    )\n  }\n  const isActive = slot === page\n  return (\n    <li>\n      <button\n        type=\"button\"\n        aria-label={`Page ${slot}`}\n        aria-current={isActive ? \"page\" : undefined}\n        onClick={() => goTo(slot)}\n        className={cn(CONTROL_CLASS, SQUARE_CLASS, isActive ? \"text-mise-foreground\" : \"text-foreground hover:bg-accent\")}\n      >\n        {isActive && <ActiveFill indicator={indicator} className=\"inset-0 -z-10 rounded-md bg-mise\" />}\n        {slot}\n      </button>\n    </li>\n  )\n}\n\nexport function NumbersView({ siblings, boundaries, ...view }: PageViewProps & { siblings?: number; boundaries?: number }) {\n  const slots = getPageWindow({ page: view.page, pageCount: view.pageCount, siblings, boundaries })\n  return (\n    <div className=\"isolate flex flex-wrap items-center justify-center gap-1\">\n      <StepButton direction=\"previous\" {...view} />\n      <ol className=\"contents\">\n        {slots.map((slot) => (\n          <NumberCell key={slot} slot={slot} page={view.page} goTo={view.goTo} indicator={view.indicator} />\n        ))}\n      </ol>\n      <StepButton direction=\"next\" {...view} />\n    </div>\n  )\n}\n\nexport function SimpleView(view: PageViewProps) {\n  return (\n    <div className=\"flex items-center justify-center gap-3\">\n      <StepButton direction=\"previous\" {...view} />\n      <p aria-live=\"polite\" className=\"text-sm text-muted-foreground tabular-nums\">\n        Page <span className=\"font-medium text-foreground\">{view.page}</span> of {Math.max(1, view.pageCount)}\n      </p>\n      <StepButton direction=\"next\" {...view} />\n    </div>\n  )\n}\n\nexport function DotsView({ page, pageCount, goTo, indicator }: PageViewProps) {\n  return (\n    <ol className=\"isolate flex flex-wrap items-center justify-center\">\n      {getDotWindow(page, pageCount).map((dot) => {\n        const isActive = dot === page\n        return (\n          <li key={dot}>\n            <button\n              type=\"button\"\n              aria-label={`Page ${dot}`}\n              aria-current={isActive ? \"page\" : undefined}\n              onClick={() => goTo(dot)}\n              className={cn(CONTROL_CLASS, \"size-10 pointer-fine:size-6\")}\n            >\n              <span className=\"size-2 rounded-full bg-muted-foreground/30\" />\n              {isActive && <ActiveFill indicator={indicator} className=\"h-2 w-4 rounded-full bg-foreground\" />}\n            </button>\n          </li>\n        )\n      })}\n    </ol>\n  )\n}\n\ninterface LoadMoreProps extends Omit<PageViewProps, \"indicator\"> {\n  itemCount: number\n  pageSize: number\n  loading: boolean\n}\n\nexport function LoadMoreView({ page, pageCount, goTo, itemCount, pageSize, loading }: LoadMoreProps) {\n  const shown = Math.min(page * pageSize, itemCount)\n  const isDone = page >= pageCount\n  const share = itemCount > 0 ? (shown / itemCount) * 100 : 0\n  return (\n    <div className=\"grid justify-items-center gap-3\">\n      <p aria-live=\"polite\" className=\"text-sm text-muted-foreground tabular-nums\">\n        Showing <span className=\"font-medium text-foreground\">{shown}</span> of {itemCount}\n      </p>\n      <span aria-hidden=\"true\" className=\"h-0.5 w-40 max-w-full overflow-hidden rounded-full bg-muted\">\n        <span\n          data-slot=\"pagination-meter\"\n          className=\"block h-full rounded-full bg-mise transition-[width] duration-(--duration-base) ease-(--ease-mise) motion-reduce:transition-none\"\n          style={{ width: `${share}%` }}\n        />\n      </span>\n      <button\n        type=\"button\"\n        disabled={isDone || loading}\n        aria-busy={loading || undefined}\n        onClick={() => goTo(page + 1)}\n        className={cn(CONTROL_CLASS, \"h-10 border border-border bg-background px-4 text-foreground hover:bg-accent\")}\n      >\n        {isDone ? \"All loaded\" : loading ? \"Loading\" : \"Load more\"}\n      </button>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/pagination-window.ts",
      "content": "export type PageSlot = number | \"ellipsis-start\" | \"ellipsis-end\"\n\ninterface PageWindowOptions {\n  /** Current page, 1-based. Clamped to the valid range. */\n  page: number\n  /** Total number of pages. */\n  pageCount: number\n  /** Pages shown either side of the current one. */\n  siblings?: number\n  /** Pages always shown at each end. */\n  boundaries?: number\n}\n\nconst range = (from: number, to: number): number[] =>\n  Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index)\n\nconst clampCount = (value: number, fallback: number) => (Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback)\n\n/**\n * The page numbers to draw, with ellipses standing in for the rest. The slot count stays the same on every page,\n * so the Next button does not move out from under the pointer while someone clicks through. An ellipsis never\n * hides a single page: that page takes the slot instead.\n */\nexport function getPageWindow({ page, pageCount, siblings = 1, boundaries = 1 }: PageWindowOptions): PageSlot[] {\n  const total = clampCount(pageCount, 0)\n  if (total === 0) return []\n  const edge = clampCount(boundaries, 1)\n  const near = clampCount(siblings, 1)\n  // Both edges, both sibling runs, the current page, and two ellipses.\n  const slots = edge * 2 + near * 2 + 3\n  if (total <= slots) return range(1, total)\n\n  const current = Math.min(total, Math.max(1, Math.floor(page) || 1))\n  const leftEdge = range(1, edge)\n  const rightEdge = range(total - edge + 1, total)\n  const showStart = current - near > edge + 2\n  const showEnd = current + near < total - edge - 1\n  // The run beside an edge fills every slot except that edge and its one ellipsis.\n  const run = slots - edge - 1\n\n  if (!showStart) return [...range(1, run), \"ellipsis-end\", ...rightEdge]\n  if (!showEnd) return [...leftEdge, \"ellipsis-start\", ...range(total - run + 1, total)]\n  return [...leftEdge, \"ellipsis-start\", ...range(current - near, current + near), \"ellipsis-end\", ...rightEdge]\n}\n\n/** A sliding run of dots, so a 40-slide carousel does not draw 40 dots. */\nexport function getDotWindow(page: number, pageCount: number, maxDots = 7): number[] {\n  const total = clampCount(pageCount, 0)\n  const shown = Math.min(total, Math.max(1, Math.floor(maxDots)))\n  if (shown === 0) return []\n  const current = Math.min(total, Math.max(1, Math.floor(page) || 1))\n  const start = Math.min(total - shown + 1, Math.max(1, current - Math.floor(shown / 2)))\n  return range(start, start + shown - 1)\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}