{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pagination",
  "type": "registry:ui",
  "title": "Pagination",
  "description": "Previous and next for a table that shows one page of many. The range comes first, in words and mono, like 21 to 40 of 143. Page numbers show only when the row is wide enough, and the buttons at either end disable.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/seven-segment.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/pagination.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/pagination 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Pagination: previous and next for a table or list that shows one page of many. The range is the main\n * information, so it comes first, in words.\n *\n * - Contract: a nav with its own name and a list inside it. The range (\"21 to 40 of 143\", \"No results\") is a polite\n *   live region, so a screen reader hears the new range after a page change, in words in every body. Previous and Next\n *   disable on the first and last page, and if the button you just pressed becomes disabled, focus moves to the other\n *   one, so it never drops to the page. The numbered pages show only when the row is 36rem wide (the row measures\n *   itself, not the window); below that the range and the two buttons are enough. A long run folds: the first, the\n *   last, and one either side of the current page, with an ellipsis between. The current page has aria-current=\"page\".\n *   44px targets on a coarse pointer. Nothing moves of its own: the current page jumps to its page rather than gliding,\n *   because the run of numbers reshapes around it. The rows it pages are the table's job (Table, animateChanges).\n * - Press, the tray: the range sits on a raised face (--litt-block, --litt-block-lift) at the start of the row, and one sunken tray\n *   (--litt-tray, --litt-tray-shade, 2px in) at its end holds the whole control: Previous and Next as raised blocks (--litt-block,\n *   --litt-block-lift) at its two ends, the pages between them. The current page is a raised block too, in --litt-fg, so a\n *   page is said by its shape and not by color. Every block's corners are the control radius less the tray's 2px, so\n *   they stay concentric. Pressed, a block sinks --litt-press-depth and its cast tightens (--litt-block-press) at once (R2,\n *   R4). At an end a block lies flat in its socket (--litt-slot, --litt-tray-shade, --litt-fg-disabled). The tray is 32px (44 on a\n *   coarse pointer), and a finger's press on another page fills it (fg 6%), as on a segment.\n * - Flat, the page: bodies/pagination.flat.tsx. Device, the machine: bodies/pagination.device.tsx.\n * - Bodies: Pagination resolves the range, the run of pages and the two ends once, into PaginationBodyProps, and renders the\n *   body the material picks (byWorld). Each body carries data-body on its nav and never changes a prop, the order of the\n *   tab stops, the live range or the focus handoff.\n */\nimport { useRef, type RefObject } from \"react\";\nimport { byWorld, useMaterial } from \"../../lib/litt/motion\";\nimport { PaginationDeviceBody } from \"./bodies/pagination.device\";\nimport { PaginationFlatBody } from \"./bodies/pagination.flat\";\n\nexport type PaginationProps = {\n  /** The nav's name, which says what is paged: \"Deploys pages\". Give each pagination on a page its own. */\n  label: string;\n  /** The current page, counting from 1. You hold it in state, and Pagination only reports changes. */\n  page: number;\n  /** How many rows a page shows. */\n  pageSize: number;\n  /** How many rows there are in all, not just on this page. */\n  total: number;\n  /** Called with the page to show next, counting from 1. Fetch or slice your rows and pass the new `page`. */\n  onPageChange: (page: number) => void;\n  /** Extra classes for the nav, such as a top margin. */\n  className?: string;\n};\n\nconst GROUP = (n: number) => n.toLocaleString(\"en-GB\");\n/** The tray is 32px; on a finger it is 44px. */\nconst TOUCH = \"pointer-coarse:h-litt-control-md\";\n/** A block's corners: the control radius less the tray's 2px, so they stay concentric. */\nconst BLOCK_RADIUS = \"rounded-[calc(var(--litt-radius-control)-2px)]\";\n\n/** The raised face the range sits on, as the Number field's figure: a block of its own at the start of the row. */\nconst FACE =\n  \"flex h-litt-control-sm items-center rounded-litt-control bg-litt-block px-3 text-litt-caption font-normal tabular-nums text-litt-fg \" +\n  \"shadow-(--litt-block-lift) forced-colors:border \" + TOUCH;\n/** The tray holds the whole control, sunk into the page. */\nconst TRAY = \"flex h-litt-control-sm items-stretch gap-0.5 rounded-litt-control bg-litt-tray p-0.5 shadow-(--litt-tray-shade) forced-colors:border \" + TOUCH;\n/** A cell of the tray: its shape and focus, with no fill yet. */\nconst CELL =\n  \"litt-target relative grid h-full cursor-pointer select-none place-items-center \" + BLOCK_RADIUS + \" \" +\n  \"text-litt-small tabular-nums [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-[background-color,color,translate] duration-(--litt-dur-instant) ease-litt \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n/** A block: Previous, Next and the current page stand up in the tray and sink under a hand. */\nconst BLOCK =\n  CELL + \" \" +\n  \"enabled:bg-litt-block enabled:text-litt-fg enabled:shadow-(--litt-block-lift) enabled:forced-colors:border \" +\n  \"enabled:hover:bg-(--litt-block-hover) enabled:active:shadow-(--litt-block-press) enabled:active:translate-y-(--litt-press-depth) \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-slot disabled:text-litt-fg-disabled disabled:shadow-(--litt-tray-shade) disabled:forced-colors:border\";\nconst END = \"px-3 font-litt-title\";\nconst PAGE = \"min-w-7 px-2 font-normal pointer-coarse:min-w-10\";\n/** Another page: a quiet word on the tray's floor until hovered, and a finger's press fills it, as on a segment. */\nconst OTHER =\n  \"cursor-pointer select-none text-litt-fg-muted hover:text-litt-fg pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] pointer-coarse:active:text-litt-fg\";\n\n/** The page numbers to show: all of them when there are few, otherwise the ends and the current page's neighbours. `null` is a gap. */\nfunction pageItems(page: number, count: number): (number | null)[] {\n  if (count <= 7) return Array.from({ length: count }, (_, i) => i + 1);\n  const keep = new Set([1, count, page - 1, page, page + 1]);\n  if (page <= 3) [2, 3, 4].forEach((n) => keep.add(n));\n  if (page >= count - 2) [count - 3, count - 2, count - 1].forEach((n) => keep.add(n));\n  const sorted = [...keep].filter((n) => n >= 1 && n <= count).sort((a, b) => a - b);\n  const out: (number | null)[] = [];\n  sorted.forEach((n, i) => {\n    if (i > 0 && n - sorted[i - 1]! > 1) out.push(null);\n    out.push(n);\n  });\n  return out;\n}\n\n/** An end button's state: whether it is off, the ref focus can return to, and what a press does. */\nexport type PaginationEnd = {\n  disabled: boolean;\n  ref: RefObject<HTMLButtonElement | null>;\n  onClick: () => void;\n};\n\n/**\n * What every body of the Pagination draws (proposals/worlds.md, Bodies). Pagination resolves it once, so a body only lays\n * out and styles. `range` is the words a screen reader hears in the live region (\"21 to 40 of 143\"); the figures\n * (`first`, `last`, `total`) are there for a body that draws them another way, and its drawing is aria-hidden. Each\n * body carries data-body on its nav.\n */\nexport type PaginationBodyProps = {\n  label: string;\n  className: string;\n  /** The range in words: \"21 to 40 of 143\", or \"No results\". */\n  range: string;\n  first: number;\n  last: number;\n  total: number;\n  /** The current page, counting from 1, and how many pages there are. */\n  current: number;\n  count: number;\n  /** The run of pages to show; `null` is a gap. */\n  items: (number | null)[];\n  previous: PaginationEnd;\n  next: PaginationEnd;\n  /** Go to a page number. */\n  onPage: (page: number) => void;\n};\n\n/** Press, the tray: the range on a raised face, then one tray with Previous and Next as blocks at its ends and the pages between. */\nfunction PressBody({ label, className, range, current, count, items, previous, next, onPage }: PaginationBodyProps) {\n  return (\n    <nav data-body=\"press\" aria-label={label} className={\"@container \" + className}>\n      <div className=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3\">\n        <p aria-live=\"polite\" aria-atomic=\"true\" className={FACE}>\n          {range}\n        </p>\n        <ul className={TRAY}>\n          <li className=\"flex\">\n            <button ref={previous.ref} type=\"button\" disabled={previous.disabled} onClick={previous.onClick} className={BLOCK + \" \" + END}>\n              <span className=\"litt-trim\">Previous</span>\n            </button>\n          </li>\n          {count > 1 &&\n            items.map((n, i) =>\n              n === null ? (\n                <li key={`gap-${i}`} aria-hidden=\"true\" className=\"hidden min-w-7 place-items-center text-litt-caption font-normal text-litt-fg-muted @min-[36rem]:grid\">\n                  …\n                </li>\n              ) : (\n                <li key={n} className=\"hidden @min-[36rem]:flex\">\n                  <button\n                    type=\"button\"\n                    aria-current={n === current ? \"page\" : undefined}\n                    aria-label={`Page ${n}`}\n                    className={(n === current ? BLOCK : CELL + \" \" + OTHER) + \" \" + PAGE}\n                    onClick={() => onPage(n)}\n                  >\n                    <span className=\"litt-trim\">{n}</span>\n                  </button>\n                </li>\n              ),\n            )}\n          <li className=\"flex\">\n            <button ref={next.ref} type=\"button\" disabled={next.disabled} onClick={next.onClick} className={BLOCK + \" \" + END}>\n              <span className=\"litt-trim\">Next</span>\n            </button>\n          </li>\n        </ul>\n      </div>\n    </nav>\n  );\n}\n\n/** The range and the buttons under a table. */\nexport function Pagination({ label, page, pageSize, total, onPageChange, className = \"\" }: PaginationProps) {\n  const count = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));\n  const current = Math.min(Math.max(1, page), count);\n  const first = total === 0 ? 0 : (current - 1) * pageSize + 1;\n  const last = Math.min(total, current * pageSize);\n  const previous = useRef<HTMLButtonElement>(null);\n  const next = useRef<HTMLButtonElement>(null);\n\n  /* A pressed button that is about to be disabled hands its focus to the other one. */\n  const go = (to: number, from?: \"previous\" | \"next\") => {\n    onPageChange(to);\n    if (from === \"previous\" && to <= 1) requestAnimationFrame(() => next.current?.focus());\n    if (from === \"next\" && to >= count) requestAnimationFrame(() => previous.current?.focus());\n  };\n\n  const Body = byWorld(useMaterial(), { press: PressBody, flat: PaginationFlatBody, device: PaginationDeviceBody });\n  return (\n    <Body\n      label={label}\n      className={className}\n      range={total === 0 ? \"No results\" : `${GROUP(first)} to ${GROUP(last)} of ${GROUP(total)}`}\n      first={first}\n      last={last}\n      total={total}\n      current={current}\n      count={count}\n      items={pageItems(current, count)}\n      previous={{ disabled: current <= 1, ref: previous, onClick: () => go(current - 1, \"previous\") }}\n      next={{ disabled: current >= count, ref: next, onClick: () => go(current + 1, \"next\") }}\n      onPage={(n) => go(n)}\n    />\n  );\n}\n",
      "target": "components/litt/pagination.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/pagination.device.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/pagination 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Pagination, the Device body (proposals/worlds.md, Bodies; material.md, 8): a readout and a row of keys.\n *\n * - The range is a panel: a bezel well (--litt-tray, --litt-tray-shade) holding three panes of LCD glass in seven segments\n *   (Seven-segment), the first row, the last and the total, with TO and OF engraved between them (litt-legend). The\n *   figures are aria-hidden; the range in words stays in the polite live region as hidden text, and the panes all\n *   take the width of the total's digits so the panel never resizes as the page changes.\n * - The keys: Previous and Next are step keys (--litt-block on --litt-key-lift, 2px skirts) at the two ends of a row, their words\n *   engraved. The page numbers are a row of keys in a well between them, in the mono (font-litt-field). The current page is\n *   latched, as the Segmented control's: sunk the whole --litt-press-depth, its face a step darker with --litt-tray-shade. A\n *   disabled step key lies flat (--litt-surface-2, a hairline, --litt-fg-disabled).\n * - Motion: nothing glides. A key sinks and a latch changes in three steps on the instant tier; reduced motion zeroes the tier.\n * - Forced colors gives the wells, the keys and the panes real borders.\n */\nimport type { CSSProperties } from \"react\";\nimport { SevenSegment } from \"../seven-segment\";\nimport type { PaginationBodyProps, PaginationEnd } from \"../pagination\";\n\n/* The detent: the sink, the colors and the cast change in three steps on the instant tier. */\nconst STEPS = \"steps(3, end)\";\nconst flip: CSSProperties = {\n  transitionProperty: \"transform, color, box-shadow, background-color\",\n  transitionDuration: \"var(--litt-dur-instant)\",\n  transitionTimingFunction: `${STEPS}, ${STEPS}, ${STEPS}, ${STEPS}`,\n};\n\nconst WELL = \"flex items-center gap-1 rounded-litt-control bg-litt-tray p-1 shadow-(--litt-tray-shade) forced-colors:border\";\n/* A key's corners: the well's less its 4px inset, so the two stay concentric. */\nconst KEY_RADIUS = \"rounded-[max(calc(var(--litt-radius-control)-4px),min(calc(var(--litt-radius-control)-2px),2px))]\";\nconst KEY =\n  \"litt-target relative grid h-8 shrink-0 cursor-pointer select-none place-items-center \" + KEY_RADIUS + \" [-webkit-tap-highlight-color:transparent] \" +\n  \"enabled:bg-litt-block enabled:text-litt-fg enabled:shadow-(--litt-key-lift) enabled:forced-colors:border \" +\n  \"enabled:hover:bg-(--litt-block-hover) enabled:active:shadow-(--litt-block-press) enabled:active:[transform:translateY(var(--litt-press-depth))] \" +\n  \"disabled:cursor-not-allowed disabled:bg-litt-surface-2 disabled:text-litt-fg-disabled disabled:shadow-[inset_0_0_0_1px_var(--litt-line)] disabled:forced-colors:border \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n/* The chosen page: latched down, a step darker, the well's own cut. */\nconst LATCHED =\n  \"bg-[color-mix(in_oklab,var(--litt-block),var(--litt-tray)_55%)] text-litt-fg shadow-(--litt-tray-shade) [transform:translateY(var(--litt-press-depth))] forced-colors:border-2\";\nconst UP = \"bg-litt-block text-litt-fg-subtle shadow-(--litt-key-lift) hover:text-litt-fg forced-colors:border\";\n\nfunction Step({ end, word }: { end: PaginationEnd; word: string }) {\n  return (\n    <button\n      ref={end.ref}\n      type=\"button\"\n      disabled={end.disabled}\n      onClick={end.onClick}\n      style={flip}\n      className={KEY + \" px-3 text-litt-caption font-litt-title litt-legend pointer-coarse:h-9\"}\n    >\n      <span className=\"litt-trim\">{word}</span>\n    </button>\n  );\n}\n\n/* A pane's corners are a key's, so they stay concentric with the well. Seven-segment's own radius gives way. */\nconst PANE = \"px-1.5! py-1! rounded-[max(calc(var(--litt-radius-control)-4px),min(calc(var(--litt-radius-control)-2px),2px))]! [font-size:calc(var(--litt-type-caption-size)*0.66)]\";\n\n/** One pane of glass: a figure in seven segments, as wide as the total's digits. */\nfunction Pane({ value, digits }: { value: number; digits: number }) {\n  return <SevenSegment value={value} digits={digits} className={PANE} />;\n}\n\n/** Device, the machine: the range in segments on an LCD panel, step keys at the ends of a row of page keys. */\nexport function PaginationDeviceBody({ label, className, range, first, last, total, current, count, items, previous, next, onPage }: PaginationBodyProps) {\n  const digits = String(total).length;\n  return (\n    <nav data-body=\"device\" aria-label={label} className={\"@container \" + className}>\n      <div className=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3\">\n        <p aria-live=\"polite\" aria-atomic=\"true\" className=\"m-0\">\n          <span className=\"sr-only\">{range}</span>\n          <span aria-hidden=\"true\" className={WELL + \" text-litt-fg-subtle\"}>\n            <Pane value={first} digits={digits} />\n            <span className=\"px-1 text-litt-caption litt-legend\">to</span>\n            <Pane value={last} digits={digits} />\n            <span className=\"px-1 text-litt-caption litt-legend\">of</span>\n            <Pane value={total} digits={digits} />\n          </span>\n        </p>\n        <ul className=\"flex items-center gap-2\">\n          <li>\n            <Step end={previous} word=\"Previous\" />\n          </li>\n          {count > 1 && (\n            <li className=\"hidden @min-[36rem]:block\">\n              <ul className={WELL}>\n                {items.map((n, i) =>\n                  n === null ? (\n                    <li key={`gap-${i}`} aria-hidden=\"true\" className=\"grid min-w-5 place-items-center font-litt-field text-litt-caption text-litt-fg-subtle\">\n                      …\n                    </li>\n                  ) : (\n                    <li key={n} className=\"flex\">\n                      <button\n                        type=\"button\"\n                        aria-current={n === current ? \"page\" : undefined}\n                        aria-label={`Page ${n}`}\n                        onClick={() => onPage(n)}\n                        style={flip}\n                        className={\n                          \"litt-target relative grid h-6 min-w-7 shrink-0 cursor-pointer select-none place-items-center px-1.5 font-litt-field text-litt-small tabular-nums \" +\n                          KEY_RADIUS + \" [-webkit-tap-highlight-color:transparent] pointer-coarse:h-8 pointer-coarse:min-w-9 \" +\n                          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n                          (n === current ? LATCHED : UP)\n                        }\n                      >\n                        <span className=\"litt-trim\">{n}</span>\n                      </button>\n                    </li>\n                  ),\n                )}\n              </ul>\n            </li>\n          )}\n          <li>\n            <Step end={next} word=\"Next\" />\n          </li>\n        </ul>\n      </div>\n    </nav>\n  );\n}\n",
      "target": "components/litt/bodies/pagination.device.tsx"
    },
    {
      "path": "registry/litt/ui/bodies/pagination.flat.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/pagination 2026.11.09. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Pagination, the Flat body (proposals/worlds.md, Bodies; material.md, 7): ink on paper. The range is a line of words in\n * ink with nothing round it, and everything a hand touches is a pill: Previous and Next are grey word pills (--litt-block is\n * --litt-surface-3 under Flat) at the end of the row, and the pages are bare numbers set between them, the current one in a\n * grey bubble (grey means chosen). No tray, no shadow, no hairline: the space between the words is the only divider.\n *\n * - A pill's press shows in its fill, a step darker under the hand (--litt-block-hover), and a finger's press on a page fills\n *   it (fg 6%). Nothing sinks and nothing casts. A disabled end is a grey pill with --litt-fg-disabled words.\n * - The pages are the figure's words in --litt-fg-muted, round pills 28px wide and 32 tall (44 on a coarse pointer), so\n *   the one bubble is a circle. The weight never changes by state.\n * - Forced colors keeps an edge on the pills and a heavier one on the current page.\n */\nimport type { PaginationBodyProps } from \"../pagination\";\n\nconst TOUCH = \"pointer-coarse:h-litt-control-md\";\n/* A pill: the one shape for what a hand touches. */\nconst PILL =\n  \"litt-target relative grid h-litt-control-sm cursor-pointer select-none place-items-center rounded-litt-pill text-litt-small \" +\n  \"[-webkit-tap-highlight-color:transparent] transition-[background-color,color] duration-(--litt-dur-instant) ease-litt \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" + TOUCH;\nconst END =\n  PILL +\n  \" px-4 font-litt-title enabled:bg-litt-block enabled:text-litt-fg enabled:hover:bg-(--litt-block-hover) enabled:forced-colors:border \" +\n  \"enabled:pointer-coarse:active:bg-(--litt-block-hover) disabled:cursor-not-allowed disabled:bg-litt-surface-3 disabled:text-litt-fg-disabled disabled:forced-colors:border\";\nconst PAGE = PILL + \" min-w-8 px-2 font-normal tabular-nums pointer-coarse:min-w-11\";\nconst CURRENT = \"bg-litt-block text-litt-fg forced-colors:border-2\";\nconst OTHER = \"bg-transparent text-litt-fg-muted hover:bg-litt-surface-3 hover:text-litt-fg pointer-coarse:active:bg-litt-surface-3 pointer-coarse:active:text-litt-fg\";\n\n/** Flat, the page: the range in ink words, grey word pills for the ends, bare numbers between. */\nexport function PaginationFlatBody({ label, className, range, current, count, items, previous, next, onPage }: PaginationBodyProps) {\n  return (\n    <nav data-body=\"flat\" aria-label={label} className={\"@container \" + className}>\n      <div className=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3\">\n        <p aria-live=\"polite\" aria-atomic=\"true\" className=\"text-litt-small font-normal tabular-nums text-litt-fg\">\n          {range}\n        </p>\n        <ul className=\"flex items-center gap-1\">\n          <li>\n            <button ref={previous.ref} type=\"button\" disabled={previous.disabled} onClick={previous.onClick} className={END}>\n              <span className=\"litt-trim\">Previous</span>\n            </button>\n          </li>\n          {count > 1 &&\n            items.map((n, i) =>\n              n === null ? (\n                <li key={`gap-${i}`} aria-hidden=\"true\" className=\"hidden min-w-6 place-items-center text-litt-small text-litt-fg-muted @min-[36rem]:grid\">\n                  …\n                </li>\n              ) : (\n                <li key={n} className=\"hidden @min-[36rem]:block\">\n                  <button\n                    type=\"button\"\n                    aria-current={n === current ? \"page\" : undefined}\n                    aria-label={`Page ${n}`}\n                    className={PAGE + \" \" + (n === current ? CURRENT : OTHER)}\n                    onClick={() => onPage(n)}\n                  >\n                    <span className=\"litt-trim\">{n}</span>\n                  </button>\n                </li>\n              ),\n            )}\n          <li>\n            <button ref={next.ref} type=\"button\" disabled={next.disabled} onClick={next.onClick} className={END}>\n              <span className=\"litt-trim\">Next</span>\n            </button>\n          </li>\n        </ul>\n      </div>\n    </nav>\n  );\n}\n",
      "target": "components/litt/bodies/pagination.flat.tsx"
    }
  ],
  "docs": "Import Pagination from components/litt/pagination. Hold the page in state and pass page, pageSize, total and onPageChange. Give it a label that says what is paged.",
  "meta": {
    "version": "2026.11.09",
    "released": "2026-11-09",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
