{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pagination",
  "type": "registry:ui",
  "title": "Pagination",
  "description": "An accessible React pagination for Next.js and Tailwind CSS: page links with ellipses, Previous and Next, and a pill that slides to the current page.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "lucide-react@^1.52.0",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/pagination.tsx",
      "type": "registry:ui",
      "content": "import { type ButtonProps, buttonVariants } from \"@/components/ui/button\";\nimport { PaginationGroup, PaginationPill } from \"@/components/ui/pagination-pill\";\nimport { cn } from \"@/lib/utils\";\nimport { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from \"lucide-react\";\nimport { Slot } from \"radix-ui\";\nimport type { ComponentProps, ReactNode } from \"react\";\n\n/*\n * Pagination (owner's roadmap, 2026-10-16: \"Move through pages of results\"), with shadcn's parts: Pagination,\n * PaginationContent, PaginationItem, PaginationLink, PaginationPrevious, PaginationNext and PaginationEllipsis.\n * - A nav landmark (\"Pagination\") holding a list. The current page has aria-current=\"page\".\n * - Links are ghost Buttons (buttonVariants), muted until hovered. A pill in Button's outline look marks the\n *   current page and slides to the new one when the page changes (pagination-pill.tsx, the one client part).\n * - PaginationLink is a link with `href`, a button without one (for pages changed in place, such as a table's),\n *   or with `asChild` your router's Link. At the first and last pages, Previous and Next are `disabled`:\n *   aria-disabled, faded and inert. A button keeps its focus (so pressing Next onto the last page doesn't drop\n *   the keyboard to the top of the page); a link loses its href.\n * - getPaginationRange works out which pages to show, with ellipses for the gaps, always the same number of\n *   items, so the links don't jump about as you move through them.\n */\n\n/** A page number, or a gap of pages not shown */\nexport type PaginationRangeItem = number | \"ellipsis\";\n\n/**\n * The pages to show around `page` (1-based) of `pageCount`: the first and last `boundaryCount` pages,\n * `siblingCount` pages either side of the current one, and \"ellipsis\" for each gap. When there are enough pages,\n * the list is always the same length (7 with the defaults), so the links keep their places.\n */\nfunction getPaginationRange({\n  page,\n  pageCount,\n  siblingCount = 1,\n  boundaryCount = 1,\n}: {\n  page: number;\n  pageCount: number;\n  siblingCount?: number;\n  boundaryCount?: number;\n}): PaginationRangeItem[] {\n  const range = (from: number, to: number) =>\n    Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index);\n  if (pageCount <= boundaryCount * 2 + siblingCount * 2 + 3) return range(1, pageCount);\n\n  const current = Math.min(Math.max(page, 1), pageCount);\n  const start = range(1, boundaryCount);\n  const end = range(pageCount - boundaryCount + 1, pageCount);\n  // The siblings slide along between the ends, keeping the same count as they near either end\n  const siblingsFrom = Math.max(\n    Math.min(current - siblingCount, pageCount - boundaryCount - siblingCount * 2 - 1),\n    boundaryCount + 2,\n  );\n  const siblingsTo = Math.min(\n    Math.max(current + siblingCount, boundaryCount + siblingCount * 2 + 2),\n    pageCount - boundaryCount - 1,\n  );\n  // A gap of one page shows that page instead of an ellipsis\n  return [\n    ...start,\n    ...(siblingsFrom > boundaryCount + 2 ? ([\"ellipsis\"] as const) : [boundaryCount + 1]),\n    ...range(siblingsFrom, siblingsTo),\n    ...(siblingsTo < pageCount - boundaryCount - 1 ? ([\"ellipsis\"] as const) : [pageCount - boundaryCount]),\n    ...end,\n  ];\n}\n\nfunction Pagination({ className, \"aria-label\": label = \"Pagination\", ...props }: ComponentProps<\"nav\">) {\n  return (\n    <nav\n      aria-label={label}\n      data-slot=\"pagination\"\n      className={cn(\"mx-auto flex w-full justify-center\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction PaginationContent({ className, children, ...props }: ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"pagination-content\"\n      // The list is the pill's layer: it slides behind every link\n      className={cn(\"relative isolate flex flex-row items-center gap-1\", className)}\n      {...props}\n    >\n      <PaginationGroup>{children}</PaginationGroup>\n    </ul>\n  );\n}\n\nfunction PaginationItem({ className, ...props }: ComponentProps<\"li\">) {\n  return <li data-slot=\"pagination-item\" className={cn(\"relative\", className)} {...props} />;\n}\n\nexport type PaginationLinkProps = ComponentProps<\"a\"> &\n  Pick<ButtonProps, \"size\"> & {\n    /** The current page: it gets the pill and aria-current=\"page\" */\n    isActive?: boolean;\n    /** Render the single child (your router's Link) with the link's styles */\n    asChild?: boolean;\n    /**\n     * Can't be followed (Previous on the first page). It's marked aria-disabled and its onClick isn't called; a\n     * link also loses its href. A button keeps its focus, so the keyboard isn't thrown out at the last page.\n     */\n    disabled?: boolean;\n  };\n\nfunction PaginationLink({\n  className,\n  isActive = false,\n  size = \"icon\",\n  asChild = false,\n  disabled = false,\n  href,\n  onClick,\n  children,\n  ...props\n}: PaginationLinkProps) {\n  const shared = {\n    \"data-slot\": \"pagination-link\",\n    \"data-active\": isActive || undefined,\n    \"aria-current\": isActive ? (\"page\" as const) : undefined,\n    \"aria-disabled\": disabled || undefined,\n    // Left out rather than wrapped, so nothing new is created here and links still render on the server\n    onClick: disabled ? undefined : onClick,\n    className: cn(\n      buttonVariants({ variant: \"ghost\", size }),\n      \"text-muted-foreground hover:text-foreground data-active:text-foreground\",\n      \"aria-disabled:pointer-events-none aria-disabled:opacity-(--opacity-disabled)\",\n      className,\n    ),\n  };\n\n  let control: ReactNode;\n  if (asChild) {\n    // The href, if any, goes onto your Link along with the styles\n    control = (\n      <Slot.Root {...shared} {...(href === undefined ? null : { href })} {...props}>\n        {children}\n      </Slot.Root>\n    );\n  } else if (href === undefined) {\n    // No href: the page changes in place, so it's a button\n    control = (\n      <button\n        type=\"button\"\n        {...(shared as ComponentProps<\"button\">)}\n        {...(props as ComponentProps<\"button\">)}\n      >\n        {children}\n      </button>\n    );\n  } else {\n    control = (\n      <a {...shared} {...(disabled ? { role: \"link\" } : { href })} {...props}>\n        {children}\n      </a>\n    );\n  }\n\n  return (\n    <>\n      {isActive ? (\n        <PaginationPill className={size === \"sm\" || size === \"icon-sm\" ? \"rounded-md\" : undefined} />\n      ) : null}\n      {control}\n    </>\n  );\n}\n\n/** Previous and Next: a chevron, and a label from the sm breakpoint. With asChild, the label goes inside your Link. */\nfunction edgeContent(asChild: boolean, children: ReactNode, parts: ReactNode[]) {\n  return asChild ? [<Slot.Slottable key=\"link\">{children}</Slot.Slottable>, ...parts] : parts;\n}\n\nfunction PaginationPrevious({ className, asChild = false, children, ...props }: PaginationLinkProps) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to previous page\"\n      size=\"md\"\n      asChild={asChild}\n      className={cn(\"gap-1 px-2.5\", className)}\n      {...props}\n    >\n      {edgeContent(asChild, children, [\n        <ChevronLeftIcon key=\"icon\" aria-hidden className=\"rtl:rotate-180\" />,\n        <span key=\"label\" className=\"hidden sm:block\">\n          Previous\n        </span>,\n      ])}\n    </PaginationLink>\n  );\n}\n\nfunction PaginationNext({ className, asChild = false, children, ...props }: PaginationLinkProps) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to next page\"\n      size=\"md\"\n      asChild={asChild}\n      className={cn(\"gap-1 px-2.5\", className)}\n      {...props}\n    >\n      {edgeContent(asChild, children, [\n        <span key=\"label\" className=\"hidden sm:block\">\n          Next\n        </span>,\n        <ChevronRightIcon key=\"icon\" aria-hidden className=\"rtl:rotate-180\" />,\n      ])}\n    </PaginationLink>\n  );\n}\n\n/** Stands for pages that aren't shown. Screen readers hear \"More pages\". */\nfunction PaginationEllipsis({ className, ...props }: ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"pagination-ellipsis\"\n      className={cn(\n        \"flex size-(--size-control-md) items-center justify-center text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      <MoreHorizontalIcon aria-hidden className=\"size-(--size-icon-md)\" />\n      <span className=\"sr-only\">More pages</span>\n    </span>\n  );\n}\n\nexport {\n  getPaginationRange,\n  Pagination,\n  PaginationContent,\n  PaginationEllipsis,\n  PaginationItem,\n  PaginationLink,\n  PaginationNext,\n  PaginationPrevious,\n};\n"
    },
    {
      "path": "registry/fewtone/ui/pagination-pill.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useId, useLayoutEffect, useRef, useState } from \"react\";\n\n/*\n * The client parts of Pagination, so the rest stays a server component (links rendered on the server work).\n * - One pill marks the current page and slides to the new one when the page changes (layoutId on\n *   spring.snappy, .agents/rules/motion.md), scoped to each Pagination, so two never trade a pill.\n * - It lies behind the link, inside the list item, rather than inside the link: the link keeps Button's own\n *   hover tint, which is clipped to it, and the pill isn't clipped while it slides.\n * - It starts where it is on first paint. With reduced motion it jumps.\n */\n\n/** One pill per Pagination */\nfunction PaginationGroup({ children }: { children: ReactNode }) {\n  const id = useId();\n  return <LayoutGroup id={id}>{children}</LayoutGroup>;\n}\n\n/** The pill behind the current page's link */\nfunction PaginationPill({ className }: { className?: string }) {\n  const reduce = useReducedMotion() ?? false;\n  const node = useRef<HTMLSpanElement>(null);\n  const [radius, setRadius] = useState<number>();\n\n  // Its corners follow the link's own (read from it), as a number, so motion keeps them true if the pill changes\n  // width between pages. The class below draws them until then, including on the server.\n  useLayoutEffect(() => {\n    const link = node.current?.parentElement?.querySelector(\"[data-slot=pagination-link]\");\n    if (link) setRadius(Number.parseFloat(getComputedStyle(link).borderTopLeftRadius) || 0);\n  }, []);\n\n  return (\n    <motion.span\n      ref={node}\n      layoutId=\"pagination-pill\"\n      aria-hidden\n      data-slot=\"pagination-pill\"\n      className={cn(\n        \"pointer-events-none absolute inset-0 -z-10 rounded-lg border border-input bg-background\",\n        className,\n      )}\n      style={radius === undefined ? undefined : { borderRadius: radius }}\n      transition={reduce ? { duration: 0 } : spring.snappy}\n    />\n  );\n}\n\nexport { PaginationGroup, PaginationPill };\n"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/pagination"
}
