{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-pagination",
  "type": "registry:ui",
  "title": "Table Pagination",
  "description": "Rows per page, rolling range, jump to page, and rows that crossfade the way you paged.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/table-pagination.tsx",
      "content": "\"use client\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport { Select } from \"@base-ui/react/select\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronLeft, ChevronRight, ChevronsUpDown, Loader } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\n\n/* -------------------------------------------------------------------------------------------------\n * Headless state\n * -----------------------------------------------------------------------------------------------*/\n\nexport type UseTablePaginationOptions = {\n  total?: number;\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  pageSize?: number;\n  defaultPageSize?: number;\n  onPageSizeChange?: (size: number) => void;\n};\n\n/** Page and page size, both controllable, plus the numbers every footer needs. Pages are 1-based. */\nexport function useTablePagination({ total, page: pageProp, defaultPage = 1, onPageChange, pageSize: sizeProp, defaultPageSize = 25, onPageSizeChange }: UseTablePaginationOptions = {}) {\n  const [rawPage, setRawPage] = useControllableState({ value: pageProp, defaultValue: defaultPage, onChange: onPageChange });\n  const [pageSize, setRawSize] = useControllableState({ value: sizeProp, defaultValue: defaultPageSize, onChange: onPageSizeChange });\n  const pageCount = total == null ? undefined : Math.max(1, Math.ceil(total / pageSize));\n  // A page past the end (rows were deleted, a filter narrowed) reads as the last page.\n  const page = pageCount ? clamp(rawPage, 1, pageCount) : Math.max(1, rawPage);\n  const from = total === 0 ? 0 : (page - 1) * pageSize + 1;\n  const to = total == null ? page * pageSize : Math.min(total, page * pageSize);\n\n  const setPage = (next: number) => setRawPage(pageCount ? clamp(next, 1, pageCount) : Math.max(1, next));\n  // Changing the size keeps the first visible row on screen instead of jumping back to page 1.\n  const setPageSize = (size: number) => {\n    const first = Math.max(0, from - 1);\n    setRawSize(size);\n    setRawPage(Math.floor(first / size) + 1);\n  };\n\n  return { page, pageSize, pageCount, from, to, setPage, setPageSize, slice: <T,>(rows: T[]) => rows.slice((page - 1) * pageSize, page * pageSize) };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The rows: a fast crossfade that leans the way you paged\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TablePageTransitionProps = Omit<React.ComponentProps<\"tbody\">, \"ref\"> & {\n  page: number;\n  /** Render as a tbody (default) or a div for grid tables. */\n  as?: \"tbody\" | \"div\";\n};\n\n// Wait mode keeps table layout honest: the old rows leave in 90ms, the new ones arrive\n// in 160ms, drifting 6px from the side you paged towards.\nexport function TablePageTransition({ page, as = \"tbody\", className, children, ...rest }: TablePageTransitionProps) {\n  const reduce = useReducedMotion();\n  const [prev, setPrev] = useState(page);\n  const [dir, setDir] = useState(1);\n  if (page !== prev) {\n    setDir(page > prev ? 1 : -1);\n    setPrev(page);\n  }\n  const Tag = as === \"tbody\" ? motion.tbody : motion.div;\n  return (\n    <AnimatePresence mode=\"wait\" initial={false} custom={dir}>\n      <Tag\n        key={page}\n        custom={dir}\n        variants={{\n          enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 6 }),\n          show: { opacity: 1, x: 0, transition: { duration: reduce ? 0.1 : 0.16, ease: ease.out } },\n          leave: (d: number) => (reduce ? { opacity: 0, transition: { duration: 0.06 } } : { opacity: 0, x: d * -4, transition: { duration: 0.09, ease: ease.in } }),\n        }}\n        initial=\"enter\"\n        animate=\"show\"\n        exit=\"leave\"\n        className={className}\n        {...(rest as object)}\n      >\n        {children}\n      </Tag>\n    </AnimatePresence>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Footer\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TablePaginationProps = Omit<React.ComponentProps<\"nav\">, \"children\"> &\n  UseTablePaginationOptions & {\n    /** Page sizes offered. Pass an empty array to hide the picker. */\n    pageSizes?: number[];\n    /** With no total (cursor pagination), whether there is a page after this one. */\n    hasNextPage?: boolean;\n    /** The requested page is loading: the button that asked for it shows a spinner in place of its arrow. */\n    busy?: boolean;\n    /** Plural noun for the range text and announcements. */\n    itemLabel?: string;\n    /** Show first and last page buttons. */\n    edges?: boolean;\n  };\n\nexport function TablePagination({\n  total,\n  page: pageProp,\n  defaultPage,\n  onPageChange,\n  pageSize: sizeProp,\n  defaultPageSize,\n  onPageSizeChange,\n  pageSizes = [10, 25, 50, 100],\n  hasNextPage,\n  busy = false,\n  itemLabel = \"rows\",\n  edges = true,\n  className,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  ...rest\n}: TablePaginationProps) {\n  const p = useTablePagination({ total, page: pageProp, defaultPage, onPageChange, pageSize: sizeProp, defaultPageSize, onPageSizeChange });\n  const [pending, setPending] = useState<\"first\" | \"prev\" | \"next\" | \"last\" | null>(null);\n  const empty = total === 0;\n  const canPrev = !empty && p.page > 1;\n  const canNext = !empty && (p.pageCount ? p.page < p.pageCount : !!hasNextPage);\n\n  const go = (target: number, which: NonNullable<typeof pending>) => {\n    // Paging again while a page loads just supersedes it; the latest request wins.\n    setPending(which);\n    p.setPage(target);\n  };\n\n  const fmt = new Intl.NumberFormat(\"en-US\");\n  const announce = empty\n    ? `No ${itemLabel}`\n    : `Page ${p.page}${p.pageCount ? ` of ${p.pageCount}` : \"\"}, ${itemLabel} ${fmt.format(p.from)} to ${fmt.format(p.to)}${total != null ? ` of ${fmt.format(total)}` : \"\"}`;\n\n  return (\n    <nav aria-label={ariaLabel} className={cn(\"@container flex min-h-11 w-full items-center gap-3 text-[12.5px] text-fg-2\", className)} {...rest}>\n      {pageSizes.length > 0 && (\n        <PageSizeSelect value={p.pageSize} sizes={pageSizes} onChange={p.setPageSize} disabled={empty} />\n      )}\n\n      <p className=\"ml-auto flex items-center whitespace-nowrap tabular @max-sm:hidden\" aria-hidden>\n        {empty ? (\n          <span>No {itemLabel}</span>\n        ) : (\n          <>\n            <NumberFlow value={p.from} className=\"tabular text-fg\" />\n            <span className=\"px-0.5 text-fg-4\">–</span>\n            <NumberFlow value={p.to} className=\"tabular text-fg\" />\n            {total != null && (\n              <>\n                <span className=\"px-1 text-fg-3\">of</span>\n                <NumberFlow value={total} className=\"tabular\" />\n              </>\n            )}\n          </>\n        )}\n      </p>\n\n      <div className=\"flex items-center gap-1 @max-sm:ml-auto\">\n        {edges && p.pageCount != null && (\n          <PageButton label=\"First page\" disabled={!canPrev} busy={busy && pending === \"first\"} onClick={() => go(1, \"first\")} className=\"@max-md:hidden\">\n            <EdgeGlyph side=\"first\" />\n          </PageButton>\n        )}\n        <PageButton label=\"Previous page\" disabled={!canPrev} busy={busy && pending === \"prev\"} onClick={() => go(p.page - 1, \"prev\")}>\n          <ChevronLeft size={16} />\n        </PageButton>\n\n        {p.pageCount != null ? (\n          <PageJump page={p.page} pageCount={p.pageCount} disabled={empty} onCommit={(n) => go(n, n > p.page ? \"next\" : \"prev\")} />\n        ) : (\n          <span className=\"px-1.5 tabular text-fg\">Page {p.page}</span>\n        )}\n\n        <PageButton label=\"Next page\" disabled={!canNext} busy={busy && pending === \"next\"} onClick={() => go(p.page + 1, \"next\")}>\n          <ChevronRight size={16} />\n        </PageButton>\n        {edges && p.pageCount != null && (\n          <PageButton label=\"Last page\" disabled={!canNext} busy={busy && pending === \"last\"} onClick={() => go(p.pageCount!, \"last\")} className=\"@max-md:hidden\">\n            <EdgeGlyph side=\"last\" />\n          </PageButton>\n        )}\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </nav>\n  );\n}\n\n// Only ever called from effects and timers.\nconst now = () => performance.now();\n\n/** A spinner that waits 150ms before showing, then stays at least 300ms so it never flickers. */\nfunction useSettledBusy(busy: boolean) {\n  const [show, setShow] = useState(false);\n  const shownAt = useRef(0);\n  useEffect(() => {\n    if (busy) {\n      const t = window.setTimeout(() => {\n        shownAt.current = now();\n        setShow(true);\n      }, 150);\n      return () => window.clearTimeout(t);\n    }\n    const t = window.setTimeout(() => setShow(false), Math.max(0, 300 - (now() - shownAt.current)));\n    return () => window.clearTimeout(t);\n  }, [busy]);\n  return show;\n}\n\nfunction PageButton({ label, busy: busyProp, disabled, className, children, ...rest }: React.ComponentProps<\"button\"> & { label: string; busy: boolean }) {\n  const reduce = useReducedMotion();\n  const busy = useSettledBusy(busyProp);\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      aria-busy={busyProp || undefined}\n      // Disabled rather than aria-disabled: at an edge there is nothing to do, and Tab shouldn't stop there.\n      disabled={disabled}\n      className={cn(\n        \"relative grid size-7 shrink-0 place-items-center rounded-md border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none\",\n        \"transition-[background-color,border-color,color,scale,opacity] duration-150 hover:border-fg-4 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"disabled:pointer-events-none disabled:opacity-40 disabled:shadow-none\",\n        \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n        busy && \"text-fg\",\n        className,\n      )}\n      {...rest}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={busy ? \"busy\" : \"idle\"}\n          className=\"grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n          transition={{ duration: 0.14, ease: ease.out }}\n        >\n          {busy ? <Loader size={14} className=\"animate-spin-slow\" /> : children}\n        </motion.span>\n      </AnimatePresence>\n    </button>\n  );\n}\n\nfunction EdgeGlyph({ side }: { side: \"first\" | \"last\" }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden focusable=\"false\">\n      {side === \"first\" ? <path d=\"M10.25 4.5 6.75 8l3.5 3.5M4.25 4.5v7\" /> : <path d=\"m5.75 4.5 3.5 3.5-3.5 3.5M11.75 4.5v7\" />}\n    </svg>\n  );\n}\n\n// \"Page [3] of 50\": type a number and press Enter, or use the arrow keys to step.\nfunction PageJump({ page, pageCount, disabled, onCommit }: { page: number; pageCount: number; disabled: boolean; onCommit: (page: number) => void }) {\n  const [draft, setDraft] = useState<number | null>(page);\n  const [synced, setSynced] = useState(page);\n  if (page !== synced) {\n    setSynced(page);\n    setDraft(page);\n  }\n  const digits = String(pageCount).length;\n  const commit = (v: number | null) => {\n    // Empty or out-of-range input settles on the nearest real page.\n    const next = v == null ? page : clamp(Math.round(v), 1, pageCount);\n    setDraft(next);\n    if (next !== page) onCommit(next);\n  };\n\n  return (\n    <NumberField.Root\n      value={draft}\n      min={1}\n      max={pageCount}\n      disabled={disabled}\n      onValueChange={(v) => setDraft(v)}\n      onValueCommitted={commit}\n      className=\"flex items-center gap-1.5 px-1 whitespace-nowrap\"\n    >\n      <span className=\"text-fg-3 @max-sm:hidden\">Page</span>\n      <NumberField.Group>\n        <NumberField.Input\n          aria-label={`Page, of ${pageCount}`}\n          onFocus={(e) => e.currentTarget.select()}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") commit(draft);\n          }}\n          style={{ width: `calc(${digits}ch + 18px)` }}\n          className={cn(\n            \"h-7 rounded-md border border-line-2 bg-frame px-2 text-center text-base text-fg tabular outline-none sm:text-[12.5px]\",\n            \"transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus:border-fg-4 focus:ring-2 focus:ring-fg/10\",\n            \"data-disabled:opacity-50\",\n          )}\n        />\n      </NumberField.Group>\n      <span className=\"text-fg-3\">\n        of <span className=\"tabular\">{pageCount.toLocaleString(\"en-US\")}</span>\n      </span>\n    </NumberField.Root>\n  );\n}\n\nfunction PageSizeSelect({ value, sizes, onChange, disabled }: { value: number; sizes: number[]; onChange: (n: number) => void; disabled: boolean }) {\n  return (\n    <div className=\"flex items-center gap-2\">\n      <Select.Root value={value} onValueChange={(v) => v != null && onChange(v as number)} disabled={disabled} items={sizes.map((s) => ({ value: s, label: String(s) }))}>\n        <Select.Label className=\"whitespace-nowrap text-fg-3 @max-md:sr-only\">Rows per page</Select.Label>\n        <Select.Trigger\n          className={cn(\n            \"group/size relative inline-flex h-7 shrink-0 items-center gap-1 rounded-md border border-line-2 bg-raised pl-2 pr-1 text-fg shadow-[var(--shadow)] outline-none\",\n            \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] data-popup-open:border-fg-4 data-popup-open:bg-hover\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n            \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          <Select.Value className=\"min-w-[3ch] tabular\">{(v: number) => <>{v}<span className=\"sr-only\"> per page</span></>}</Select.Value>\n          <Select.Icon className=\"text-fg-3 transition-colors group-hover/size:text-fg-2\">\n            <ChevronsUpDown size={14} />\n          </Select.Icon>\n        </Select.Trigger>\n        <Select.Portal>\n          <Select.Positioner side=\"top\" align=\"start\" sideOffset={6} alignItemWithTrigger={false} collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n            <Select.Popup\n              className={cn(\n                \"min-w-[max(5.5rem,var(--anchor-width))] rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n                \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n                \"data-starting-style:scale-96 data-starting-style:opacity-0 data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1\",\n                \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n                \"data-instant:duration-0 motion-reduce:scale-100 motion-reduce:translate-none\",\n              )}\n            >\n              <Select.List>\n                {sizes.map((s) => (\n                  <Select.Item\n                    key={s}\n                    value={s}\n                    className=\"flex h-8 cursor-default select-none items-center gap-2 rounded-lg pl-2 pr-2.5 text-[13px] tabular outline-none transition-colors duration-100 data-highlighted:bg-hover\"\n                  >\n                    <span className=\"grid size-3.5 place-items-center text-fg\">\n                      <Select.ItemIndicator className=\"grid place-items-center\">\n                        <Check size={14} />\n                      </Select.ItemIndicator>\n                    </span>\n                    <Select.ItemText>{s}</Select.ItemText>\n                  </Select.Item>\n                ))}\n              </Select.List>\n            </Select.Popup>\n          </Select.Positioner>\n        </Select.Portal>\n      </Select.Root>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/table-pagination"
}
