{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pagination-clean","type":"registry:ui","title":"Pagination (Clean)","description":"Pages of a long list – the range you see, previous and next, page numbers with … between them and rows per page, tabular numbers that keep the row still from 1 page to 10,000","dependencies":[],"files":[{"path":"registry/pagination-clean/pagination-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties } from \"react\";\n\n/*\n * Pagination – a single self-contained file, clean style.\n * Moves through pages of rows: the range you see (\"1–20 of 1,284\"), previous and next, page numbers with … between\n * them and, when you give it sizes, a rows-per-page choice. The numbers keep a fixed count of slots and one width,\n * so nothing jumps from page to page, 1 page or 10,000. When the row gets too narrow for the numbers, only the range\n * and the arrows stay. The styles are injected below, so no extra files are needed.\n */\n\nexport type PaginationProps = Omit<ComponentPropsWithRef<\"nav\">, \"children\" | \"onChange\"> & {\n  /** The page shown, from 1 */\n  page: number;\n  onPageChange: (page: number) => void;\n  /** All rows – shows the range (\"1–20 of 1,284\") and works out the pages */\n  total?: number;\n  /** Rows on a page */\n  pageSize?: number;\n  /** The number of pages, when you don't know the total rows */\n  pageCount?: number;\n  /** Shows a rows-per-page choice with these sizes, e.g. [10, 20, 50] */\n  pageSizes?: number[];\n  /** A new size; the page that holds the first row you saw is passed to onPageChange as well */\n  onPageSizeChange?: (pageSize: number) => void;\n  /** Page numbers on each side of the current one */\n  siblings?: number;\n  /** Only the range and the arrows – no page numbers, e.g. under a small table */\n  compact?: boolean;\n  /** While rows load: shown, but nothing can be pressed */\n  disabled?: boolean;\n};\n\nconst fmt = new Intl.NumberFormat(\"en-US\");\n\nexport function Pagination({\n  page: pageProp,\n  onPageChange,\n  total,\n  pageSize = 20,\n  pageCount,\n  pageSizes,\n  onPageSizeChange,\n  siblings = 1,\n  compact = false,\n  disabled = false,\n  className,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  ref,\n  ...rest\n}: PaginationProps) {\n  const uid = useId();\n  const size = Math.max(1, Math.floor(pageSize) || 1);\n  const rows = total != null && Number.isFinite(total) ? Math.max(0, Math.floor(total)) : null;\n  const count = Math.max(1, rows != null ? Math.ceil(rows / size) : Math.floor(pageCount ?? 1) || 1);\n  const page = Math.min(count, Math.max(1, Math.floor(pageProp) || 1));\n  const sib = Math.max(0, Math.floor(siblings) || 0);\n\n  // Too narrow for the numbers: only the range and the arrows (checked before paint, again when it gets wider)\n  const nav = useRef<HTMLElement | null>(null);\n  const [tight, setTight] = useState(false);\n  const tightAt = useRef(0);\n  // More pages (wider numbers) or other slots: measure again\n  const sig = `${count}|${sib}|${compact}`;\n  const [seen, setSeen] = useState(sig);\n  if (seen !== sig) {\n    setSeen(sig);\n    setTight(false);\n  }\n  useLayoutEffect(() => {\n    const el = nav.current;\n    if (!el || tight || compact) return;\n    if (el.scrollWidth > el.clientWidth + 1) {\n      tightAt.current = el.clientWidth;\n      setTight(true);\n    }\n  });\n  useEffect(() => {\n    const el = nav.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(() => {\n      if (el.clientWidth > tightAt.current + 1) setTight(false);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // After a number is pressed, the focus follows the page to its new slot\n  const follow = useRef(false);\n  useLayoutEffect(() => {\n    if (!follow.current) return;\n    follow.current = false;\n    nav.current?.querySelector<HTMLElement>(`[data-page=\"${page}\"]`)?.focus({ preventScroll: true });\n  }, [page]);\n\n  const go = (to: number, fromNumber = false) => {\n    if (disabled) return;\n    const next = Math.min(count, Math.max(1, to));\n    if (next === page) return;\n    follow.current = fromNumber && !!nav.current?.contains(document.activeElement);\n    onPageChange(next);\n  };\n\n  const first = rows === 0 ? 0 : (page - 1) * size + 1;\n  const last = rows == null ? 0 : Math.min(page * size, rows);\n  const numbers = !compact && !tight;\n  const sizes = pageSizes?.length ? [...new Set([...pageSizes, size])].filter((s) => s > 0).sort((a, b) => a - b) : null;\n\n  return (\n    <nav\n      {...rest}\n      ref={(el) => {\n        nav.current = el;\n        if (typeof ref === \"function\") ref(el);\n        else if (ref) ref.current = el;\n      }}\n      aria-label={ariaLabel}\n      className={[\"ui-pgnc\", className].filter(Boolean).join(\" \")}\n      data-disabled={disabled || undefined}\n      style={{ ...rest.style, \"--ui-pgnc-digits\": fmt.format(count).length } as CSSProperties}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many pagers render */}\n      <style href=\"ui-pagination-clean\" precedence=\"default\">\n        {css}\n      </style>\n\n      <div className=\"ui-pgnc__info\">\n        {sizes && (\n          <label className=\"ui-pgnc__size\">\n            <span className=\"ui-pgnc__size-label\">Rows per page</span>\n            <span className=\"ui-pgnc__select\">\n              <select\n                id={`${uid}-size`}\n                value={size}\n                disabled={disabled}\n                onChange={(e) => {\n                  const next = Number(e.target.value);\n                  onPageSizeChange?.(next);\n                  // Keep the first row you saw on screen\n                  const keep = Math.floor(((page - 1) * size) / next) + 1;\n                  if (keep !== page) onPageChange(keep);\n                }}\n              >\n                {sizes.map((s) => (\n                  <option key={s} value={s}>\n                    {fmt.format(s)}\n                  </option>\n                ))}\n              </select>\n              <ChevronDownIcon />\n            </span>\n          </label>\n        )}\n        <span className=\"ui-pgnc__range\" aria-live=\"polite\" aria-atomic=\"true\">\n          {rows != null ? (\n            rows === 0 ? (\n              \"0 of 0\"\n            ) : (\n              <>\n                {fmt.format(first)}–{fmt.format(last)} <span className=\"ui-pgnc__of\">of</span> {fmt.format(rows)}\n              </>\n            )\n          ) : (\n            <>\n              Page {fmt.format(page)} <span className=\"ui-pgnc__of\">of</span> {fmt.format(count)}\n            </>\n          )}\n        </span>\n      </div>\n\n      <ul className=\"ui-pgnc__pages\">\n        <li>\n          <button\n            type=\"button\"\n            className=\"ui-pgnc__step\"\n            aria-label=\"Previous page\"\n            aria-disabled={disabled || page <= 1 || undefined}\n            onClick={() => go(page - 1)}\n          >\n            <ChevronIcon dir=\"left\" />\n          </button>\n        </li>\n        {numbers &&\n          slots(page, count, sib).map((p, i) =>\n            p === 0 ? (\n              <li key={i} className=\"ui-pgnc__gap\" aria-hidden=\"true\">\n                …\n              </li>\n            ) : (\n              <li key={i}>\n                <button\n                  type=\"button\"\n                  className=\"ui-pgnc__num\"\n                  data-page={p}\n                  aria-label={`Page ${fmt.format(p)}`}\n                  aria-current={p === page ? \"page\" : undefined}\n                  aria-disabled={disabled || undefined}\n                  onClick={() => go(p, true)}\n                >\n                  {fmt.format(p)}\n                </button>\n              </li>\n            ),\n          )}\n        <li>\n          <button\n            type=\"button\"\n            className=\"ui-pgnc__step\"\n            aria-label=\"Next page\"\n            aria-disabled={disabled || page >= count || undefined}\n            onClick={() => go(page + 1)}\n          >\n            <ChevronIcon dir=\"right\" />\n          </button>\n        </li>\n      </ul>\n    </nav>\n  );\n}\n\n/**\n * The page numbers – always the same number of slots once there are more pages than fit (first, last, the current\n * with its siblings and a … on either side), so the row never changes width. 0 stands for …\n */\nfunction slots(page: number, count: number, sib: number): number[] {\n  const n = 2 * sib + 5;\n  const run = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => from + i);\n  if (count <= n) return run(1, count);\n  if (page - sib <= 3) return [...run(1, n - 2), 0, count];\n  if (page + sib >= count - 2) return [1, 0, ...run(count - (n - 3), count)];\n  return [1, 0, ...run(page - sib, page + sib), 0, count];\n}\n\n/* Icons: Hugeicons Free (MIT, © Hugeicons, hugeicons.com) */\nfunction ChevronIcon({ dir }: { dir: \"left\" | \"right\" }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" focusable=\"false\" aria-hidden=\"true\">\n      <path d={dir === \"left\" ? \"M15 6s-6 4.42-6 6 6 6 6 6\" : \"M9 6s6 4.42 6 6-6 6-6 6\"} />\n    </svg>\n  );\n}\nfunction ChevronDownIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" focusable=\"false\" aria-hidden=\"true\">\n      <path d=\"M18 9s-4.42 6-6 6-6-6-6-6\" />\n    </svg>\n  );\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors as variables – override them on .ui-pgnc (see Theming in the docs) */\n.ui-pgnc {\n  --ui-pgnc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-pgnc-text: rgba(0, 0, 0, 0.61);\n  --ui-pgnc-muted: var(--aui-muted, rgba(0, 0, 0, 0.45));\n  --ui-pgnc-faint: var(--aui-placeholder, rgba(0, 0, 0, 0.27));\n  --ui-pgnc-wash: rgba(0, 0, 0, 0.04);\n  --ui-pgnc-active: var(--aui-subtle, rgba(0, 0, 0, 0.06));\n  --ui-pgnc-surface: var(--aui-surface, #ffffff);\n  --ui-pgnc-shadow: 0 0 0 0.5px var(--aui-border, rgba(0, 0, 0, 0.06)), 0 1px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --ui-pgnc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(0, 0, 0, 0.1)), 0 2px 6px -1px rgba(0, 0, 0, 0.08), 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --ui-pgnc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-pgnc-radius: var(--aui-radius, 6px);\n  --ui-pgnc-h: 28px;\n\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: space-between;\n  gap: 8px 16px;\n  min-width: 0;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13px;\n  line-height: 20px;\n  font-weight: 400;\n  font-variant-numeric: tabular-nums;\n  color: var(--ui-pgnc-muted);\n  white-space: nowrap;\n  -webkit-font-smoothing: antialiased;\n}\n.ui-pgnc *, .ui-pgnc *::before, .ui-pgnc *::after { box-sizing: border-box; }\n.ui-pgnc svg { display: block; flex: none; }\n\n.ui-pgnc__info { display: flex; align-items: center; gap: 16px; min-width: 0; }\n.ui-pgnc__range { color: var(--ui-pgnc-text); }\n.ui-pgnc__of { color: var(--ui-pgnc-muted); }\n\n/* Rows per page: a quiet label and a small white field – the browser's own list opens from it */\n.ui-pgnc__size { display: inline-flex; align-items: center; gap: 8px; cursor: default; }\n.ui-pgnc__select { position: relative; display: inline-flex; align-items: center; }\n.ui-pgnc__select > select {\n  appearance: none;\n  height: var(--ui-pgnc-h);\n  margin: 0;\n  padding: 0 26px 0 10px;\n  border: 0;\n  border-radius: var(--ui-pgnc-radius);\n  background: var(--ui-pgnc-surface);\n  box-shadow: var(--ui-pgnc-shadow);\n  font: inherit;\n  font-weight: 500;\n  color: var(--ui-pgnc-ink);\n  cursor: pointer;\n  transition: box-shadow 140ms ease;\n}\n.ui-pgnc__select > select:hover:not(:disabled) { box-shadow: var(--ui-pgnc-shadow-hover); }\n.ui-pgnc__select > svg { position: absolute; right: 8px; width: 12px; height: 12px; color: var(--ui-pgnc-muted); pointer-events: none; }\n.ui-pgnc__select > select:dir(rtl) { padding: 0 10px 0 26px; }\n.ui-pgnc__select > select:dir(rtl) + svg { right: auto; left: 8px; }\n\n/* The arrows and the numbers: one row, every number as wide as the longest */\n.ui-pgnc__pages { display: flex; align-items: center; gap: 2px; margin: 0 0 0 auto; padding: 0; list-style: none; }\n.ui-pgnc__pages > li { display: flex; }\n.ui-pgnc__num,\n.ui-pgnc__step {\n  position: relative;\n  display: inline-grid;\n  place-items: center;\n  height: var(--ui-pgnc-h);\n  margin: 0;\n  padding: 0;\n  border: 0;\n  border-radius: var(--ui-pgnc-radius);\n  background: none;\n  font: inherit;\n  color: var(--ui-pgnc-text);\n  cursor: pointer;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color 120ms ease, color 120ms ease;\n}\n.ui-pgnc__num { min-width: max(var(--ui-pgnc-h), calc(var(--ui-pgnc-digits, 1) * 1ch + 14px)); padding: 0 7px; }\n.ui-pgnc__step { width: var(--ui-pgnc-h); color: var(--ui-pgnc-muted); }\n.ui-pgnc__step > svg { width: 14px; height: 14px; }\n.ui-pgnc__step > svg:dir(rtl) { transform: scaleX(-1); }\n.ui-pgnc__num:hover:not([aria-disabled]),\n.ui-pgnc__step:hover:not([aria-disabled]) { background: var(--ui-pgnc-wash); color: var(--ui-pgnc-ink); }\n/* The page you're on: a gray fill, like an active tab */\n.ui-pgnc__num[aria-current=\"page\"],\n.ui-pgnc__num[aria-current=\"page\"]:hover { background: var(--ui-pgnc-active); color: var(--ui-pgnc-ink); font-weight: 500; cursor: default; }\n.ui-pgnc__step[aria-disabled] { opacity: 0.4; cursor: default; }\n.ui-pgnc__num:focus-visible,\n.ui-pgnc__step:focus-visible,\n.ui-pgnc__select > select:focus-visible { outline: 2px solid var(--ui-pgnc-focus); outline-offset: 2px; }\n.ui-pgnc__select > select:focus:not(:focus-visible) { outline: none; }\n.ui-pgnc__gap { display: grid; place-items: center; min-width: 20px; height: var(--ui-pgnc-h); color: var(--ui-pgnc-faint); user-select: none; }\n\n/* While rows load: everything stays, nothing can be pressed */\n.ui-pgnc[data-disabled] .ui-pgnc__pages,\n.ui-pgnc[data-disabled] .ui-pgnc__size { opacity: 0.5; }\n.ui-pgnc[data-disabled] button { cursor: default; }\n.ui-pgnc[data-disabled] .ui-pgnc__num[aria-current=\"page\"] { background: var(--ui-pgnc-active); }\n\n/* Touch screens: every number and arrow a finger can hit */\n@media (pointer: coarse) {\n  .ui-pgnc { --ui-pgnc-h: 36px; font-size: 15px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-pgnc__num, .ui-pgnc__step, .ui-pgnc__select > select { transition-duration: 1ms; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-pgnc {\n  --ui-pgnc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-pgnc-text: rgba(255, 255, 255, 0.62);\n  --ui-pgnc-muted: var(--aui-muted, rgba(255, 255, 255, 0.45));\n  --ui-pgnc-faint: var(--aui-placeholder, rgba(255, 255, 255, 0.28));\n  --ui-pgnc-wash: rgba(255, 255, 255, 0.06);\n  --ui-pgnc-active: var(--aui-subtle, rgba(255, 255, 255, 0.09));\n  --ui-pgnc-surface: var(--aui-surface, #1b1b1e);\n  --ui-pgnc-shadow: 0 0 0 0.5px var(--aui-border, rgba(255, 255, 255, 0.1)), 0 1px 2px rgba(0, 0, 0, 0.4);\n  --ui-pgnc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(255, 255, 255, 0.16)), 0 2px 6px rgba(0, 0, 0, 0.4);\n  --ui-pgnc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n  color-scheme: dark;\n}\n`;\n"}]}