{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pagination-dog-ear",
  "title": "Pagination Dog Ear",
  "description": "A pagination control rendered as book folios: paper cards for each page number, a raised current page, and a CSS corner dog-ear that curls further as you hover prev/next to peek the destination number, leaving a small permanent crease on every page you've visited.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/pagination-dog-ear/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FolioTurn — pagination rendered as a row of book folios instead of bare\n// numbers. Every button is a paper \"card\"; the current one sits 1px raised\n// with a soft shadow. Each card carries a corner dog-ear built from the\n// classic CSS border-triangle technique (an element with all size in\n// border-width and only two adjacent border colors opaque) so it costs no\n// extra markup layer — just a --curl custom property driving border-width.\n//\n// Three distinct curl behaviors, all on that same triangle:\n//   - hovering ANY page number: its own corner dog-ear grows a few px and\n//     its ink darkens (a light \"this is interactive\" tell).\n//   - hovering prev/next: the CURRENT page's corner grows further and a\n//     small preview of the destination number fades in beside it — you're\n//     peeking at what prev/next would turn to, not what you're hovering.\n//   - completing a turn (the `page` prop actually changes, from any\n//     trigger): the outgoing page's corner grows the rest of the way while\n//     the card itself gets a brief eased tilt+fade, then the new current\n//     page settles into its raised resting state. A once-visited page\n//     keeps a small permanent crease (a second, smaller, static triangle)\n//     forever after.\n//\n// Keyboard is a roving-tabindex toolbar (prev, every page number, next, all\n// one tab stop): ArrowLeft/ArrowRight move focus by one among the ENABLED\n// controls, Home/End jump to the first/last, matching the roving pattern\n// used elsewhere in this registry (dropdown-drape, accordion-latch) rather than a\n// custom listbox. Enter/Space activate the focused control natively.\n// `<nav aria-label>` + `aria-current=\"page\"` on the current button is the\n// only semantic layer; there is no live region because the page number\n// itself, visibly raised, is the state.\n//\n// prefers-reduced-motion: no curl growth, no tilt/fade on turn, pages swap\n// instantly — the permanent visited-crease still appears (it is not an\n// animation, just a static mark) so history stays visible either way.\n// ---------------------------------------------------------------------------\n\nexport interface FolioTurnProps {\n  /** Current page, 1-indexed. */\n  page: number;\n  /** Total number of pages. */\n  count: number;\n  /** called with the new 1-indexed page when a different page is chosen */\n  onChange: (page: number) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst TURN_MS = 280;\n\nexport function FolioTurn({ page, count, onChange, className = \"\" }: FolioTurnProps) {\n  const [hoverPage, setHoverPage] = useState<number | null>(null);\n  const [peekDir, setPeekDir] = useState<0 | -1 | 1>(0);\n  const [turning, setTurning] = useState<{ from: number } | null>(null);\n  const [visited, setVisited] = useState<Set<number>>(() => new Set([page]));\n  const reducedRef = useRef(false);\n  const prevPageRef = useRef(page);\n  const turnTimerRef = useRef<number | undefined>(undefined);\n\n  // Flat control list: index 0 = prev, 1..count = page numbers, count+1 = next.\n  const total = count + 2;\n  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const isDisabled = (i: number) => (i === 0 && page <= 1) || (i === total - 1 && page >= count);\n  const enabledIndices = Array.from({ length: total }, (_, i) => i).filter((i) => !isDisabled(i));\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onMq = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onMq);\n    return () => mq.removeEventListener(\"change\", onMq);\n  }, []);\n\n  useEffect(() => {\n    const prev = prevPageRef.current;\n    prevPageRef.current = page;\n    setVisited((v) => (v.has(page) ? v : new Set(v).add(page)));\n    if (prev === page) return;\n    window.clearTimeout(turnTimerRef.current);\n    if (reducedRef.current) return;\n    setTurning({ from: prev });\n    turnTimerRef.current = window.setTimeout(() => setTurning(null), TURN_MS);\n    return () => window.clearTimeout(turnTimerRef.current);\n  }, [page]);\n\n  const onKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {\n    const pos = enabledIndices.indexOf(i);\n    let nextPos = -1;\n    if (e.key === \"ArrowLeft\") nextPos = pos <= 0 ? enabledIndices.length - 1 : pos - 1;\n    else if (e.key === \"ArrowRight\") nextPos = (pos + 1) % enabledIndices.length;\n    else if (e.key === \"Home\") nextPos = 0;\n    else if (e.key === \"End\") nextPos = enabledIndices.length - 1;\n    if (nextPos >= 0) {\n      e.preventDefault();\n      btnRefs.current[enabledIndices[nextPos]!]?.focus();\n    }\n  };\n\n  const go = (n: number) => {\n    if (n < 1 || n > count || n === page) return;\n    onChange(n);\n  };\n\n  return (\n    <nav aria-label=\"Pagination\" className={`ns-ft-nav ${className}`}>\n      <style>{CSS}</style>\n      <ul className=\"ns-ft-list\">\n        <li>\n          <button\n            ref={(el) => {\n              btnRefs.current[0] = el;\n            }}\n            type=\"button\"\n            aria-label=\"Previous page\"\n            disabled={page <= 1}\n            tabIndex={enabledIndices[0] === 0 ? 0 : -1}\n            className=\"ns-ft-nav-btn\"\n            onClick={() => go(page - 1)}\n            onKeyDown={(e) => onKeyDown(e, 0)}\n            onPointerEnter={() => setPeekDir(-1)}\n            onPointerLeave={() => setPeekDir(0)}\n          >\n            ‹\n          </button>\n        </li>\n        {Array.from({ length: count }, (_, i) => i + 1).map((n) => {\n          const flatIndex = n;\n          const isCurrent = n === page;\n          const isHover = hoverPage === n;\n          const isVisited = visited.has(n) && !isCurrent;\n          const isTurningFrom = turning?.from === n;\n          const showNavPeek = isCurrent && peekDir !== 0;\n          const peekNumber = showNavPeek ? page + peekDir : null;\n          const curl = isTurningFrom ? 1 : isHover ? 0.55 : showNavPeek ? 0.7 : 0;\n          return (\n            <li key={n} className=\"ns-ft-item\">\n              <button\n                ref={(el) => {\n                  btnRefs.current[flatIndex] = el;\n                }}\n                type=\"button\"\n                aria-label={`Page ${n}`}\n                aria-current={isCurrent ? \"page\" : undefined}\n                tabIndex={enabledIndices[0] === flatIndex ? 0 : -1}\n                className=\"ns-ft-page\"\n                data-current={isCurrent || undefined}\n                data-turning={isTurningFrom || undefined}\n                data-visited={isVisited || undefined}\n                data-peek={showNavPeek || undefined}\n                style={{ \"--curl\": curl } as React.CSSProperties}\n                onClick={() => go(n)}\n                onKeyDown={(e) => onKeyDown(e, flatIndex)}\n                onPointerEnter={() => setHoverPage(n)}\n                onPointerLeave={() => setHoverPage((h) => (h === n ? null : h))}\n              >\n                <span className=\"ns-ft-num\">{n}</span>\n                {peekNumber && peekNumber >= 1 && peekNumber <= count && (\n                  <span className=\"ns-ft-peek\" aria-hidden=\"true\">\n                    {peekNumber}\n                  </span>\n                )}\n                <span className=\"ns-ft-curl\" aria-hidden=\"true\" />\n                {isVisited && <span className=\"ns-ft-crease\" aria-hidden=\"true\" />}\n              </button>\n            </li>\n          );\n        })}\n        <li>\n          <button\n            ref={(el) => {\n              btnRefs.current[total - 1] = el;\n            }}\n            type=\"button\"\n            aria-label=\"Next page\"\n            disabled={page >= count}\n            tabIndex={enabledIndices[0] === total - 1 ? 0 : -1}\n            className=\"ns-ft-nav-btn\"\n            onClick={() => go(page + 1)}\n            onKeyDown={(e) => onKeyDown(e, total - 1)}\n            onPointerEnter={() => setPeekDir(1)}\n            onPointerLeave={() => setPeekDir(0)}\n          >\n            ›\n          </button>\n        </li>\n      </ul>\n    </nav>\n  );\n}\n\nconst CSS = `\n.ns-ft-nav{display:inline-block;}\n.ns-ft-list{display:flex;align-items:flex-end;gap:6px;list-style:none;margin:0;padding:0;}\n.ns-ft-nav-btn{\n  display:flex;align-items:center;justify-content:center;\n  width:32px;height:38px;\n  border:1px solid var(--border);border-radius:4px;\n  background:var(--background);color:var(--ns-muted);\n  font-size:16px;line-height:1;cursor:pointer;\n  transition:color 150ms ease, border-color 150ms ease;\n}\n.ns-ft-nav-btn:not(:disabled):hover{color:var(--foreground);border-color:var(--ns-muted);}\n.ns-ft-nav-btn:disabled{opacity:0.35;cursor:default;}\n.ns-ft-nav-btn:focus-visible{outline:2px solid var(--ns-accent);outline-offset:1px;}\n\n.ns-ft-item{position:relative;}\n.ns-ft-page{\n  --curl:0;\n  position:relative;\n  overflow:hidden;\n  width:34px;height:40px;\n  border:1px solid var(--border);border-radius:4px;\n  background:var(--background);\n  color:var(--ns-muted);\n  font-family:var(--font-geist-mono, ui-monospace, monospace);\n  font-size:13px;\n  cursor:pointer;\n  transform:translateY(0);\n  box-shadow:none;\n  transition:color 150ms ease, transform 200ms cubic-bezier(0.16,1,0.3,1), box-shadow 200ms ease, border-color 150ms ease;\n}\n.ns-ft-page:hover{color:var(--foreground);border-color:var(--ns-muted);}\n.ns-ft-page:focus-visible{outline:2px solid var(--ns-accent);outline-offset:1px;}\n.ns-ft-page[data-current]{\n  color:var(--foreground);\n  transform:translateY(-1px);\n  box-shadow:0 2px 4px -1px rgba(0,0,0,0.35);\n  border-color:var(--foreground);\n}\n.ns-ft-page[data-turning]{\n  transition:transform ${TURN_MS}ms cubic-bezier(0.55,0,0.85,0.35), opacity ${TURN_MS}ms ease;\n  transform:translateY(-1px) rotateZ(-3deg) translateX(-2px);\n  opacity:0.75;\n}\n\n.ns-ft-num{position:relative;z-index:1;}\n.ns-ft-peek{\n  position:absolute;\n  top:3px;right:3px;\n  font-size:9px;\n  color:var(--ns-muted);\n  opacity:0;\n  transition:opacity 200ms ease;\n  z-index:1;\n}\n.ns-ft-page[data-peek] .ns-ft-peek{opacity:1;}\n\n.ns-ft-curl{\n  position:absolute;\n  top:0;right:0;\n  width:0;height:0;\n  border-style:solid;\n  border-color:transparent var(--border) transparent transparent;\n  border-width:0 calc(var(--curl) * 16px) calc(var(--curl) * 16px) 0;\n  transition:border-width 220ms cubic-bezier(0.22,1,0.36,1);\n}\n\n.ns-ft-crease{\n  position:absolute;\n  bottom:0;right:0;\n  width:0;height:0;\n  border-style:solid;\n  border-width:0 0 7px 7px;\n  border-color:transparent transparent var(--border) transparent;\n}\n\n@media (prefers-reduced-motion: reduce){\n  .ns-ft-page,.ns-ft-curl,.ns-ft-peek{transition:none !important;}\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/pagination-dog-ear.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "pagination",
      "navigation",
      "nav",
      "dog-ear",
      "aria-current",
      "roving-tabindex",
      "book"
    ],
    "instruction": "Build a pagination control (`page: number` 1-indexed, `count: number`, `onChange: (page: number) => void`) rendered as a row of book folios rather than bare numbers. Every page number is a `<button>` 'card' (fixed size, hairline `var(--border)` outline, `var(--background)` fill, Geist Mono glyph) inside a `<nav aria-label=\"Pagination\">`; the current page carries `aria-current=\"page\"`, sits `translateY(-1px)` raised with a soft `box-shadow`, and its border darkens to `var(--foreground)`. Every card has a corner dog-ear built from the classic CSS border-triangle technique — an absolutely positioned 0x0 element whose `border-width` on two adjacent sides is driven by a `--curl` custom property (0 to 1) and whose other two border sides stay `transparent`, so the shape is a right-triangle wedge in the top-right corner with zero extra DOM. `--curl` responds to three distinct situations, each with a different target value and reason: (1) hovering ANY page's own button sets that button's `--curl` to ~0.55 and darkens its ink from `var(--ns-muted)` to `var(--foreground)` — a light 'this is interactive' tell on every number, not just the current one; (2) hovering the prev/next control (which does NOT change `--curl` on itself) instead sets the CURRENT page's `--curl` to ~0.7 and reveals a small absolutely-positioned preview span near the corner showing `page + direction` (clamped to a valid page, hidden otherwise) — this is peeking at the destination the turn would land on, so it lives on the current card, not the one under the cursor; (3) completing an actual page change (react to the `page` prop changing, not just the click that caused it, so external control changes animate identically) plays the OUTGOING page's `--curl` to a full 1 while that same card also gets a brief 280ms eased tilt (`transform: translateY(-1px) rotateZ(-3deg) translateX(-2px)`, `cubic-bezier(0.55,0,0.85,0.35)`) and opacity dip to 0.75, reverting automatically once the timer clears — this reads as the page lifting and turning away as the new one becomes current. Every page that has ever been the current page (tracked in a `Set<number>`, seeded with the initial page) keeps a SECOND, smaller, permanently static triangle in the opposite (bottom-right) corner once it's no longer current — a fixed-size crease independent of `--curl`, so page history stays visibly marked even after the interactive curl relaxes back to 0. Keyboard is a roving-tabindex toolbar spanning prev, every page button, and next as one sequence: only one control is ever `tabIndex=0` (the first non-disabled control, recalculated as availability changes at the ends), `ArrowLeft`/`ArrowRight` move focus to the previous/next ENABLED control (wrapping), `Home`/`End` jump to the first/last enabled control, and Enter/Space activate the focused button via native `<button>` behavior — no custom activation handling needed. Prev is `disabled` at page 1, next at the last page, both with a visible dimmed state and excluded from the roving sequence while disabled. `prefers-reduced-motion: reduce` removes the curl-growth, tilt, and peek-fade transitions entirely (everything still functions, `--curl` and the turn state still flip, but with `transition:none` so page changes are instant); the small permanent visited-crease is not an animation and is unaffected either way — it should still mark history even with motion off. Zero dependencies, no SVG, no canvas, no scrollHeight measurement."
  },
  "type": "registry:ui"
}