{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-area",
  "title": "Scroll area",
  "description": "A rule appears at an edge only while there's more beyond it, and leaves when you reach the end.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/scrollbar.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/scroll-area.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\nimport { Scrollbar, type ScrollbarSection } from \"@/components/ui/scrollbar\"\n\ntype ScrollAreaProps = React.ComponentProps<\"div\"> & {\n  /** Marks on the rail, one per section inside: pointing at the rail brings their numbers down it, and pressing one scrolls there. */\n  sections?: ScrollbarSection[]\n  /** ruled: a rule at an edge while there's more beyond it. catchword: the first word below the edge waits on the foot rule. wheel: the rows turn on an arc as it scrolls. */\n  variant?: \"ruled\" | \"catchword\" | \"wheel\"\n}\n\n/** The first word whose line runs under the foot line (the edge), and where it stands, or null at the end. */\nfunction wordBelow(box: HTMLElement, foot: HTMLElement) {\n  if (box.scrollTop + box.clientHeight >= box.scrollHeight - 1) return null\n  const edge = foot.getBoundingClientRect().top + 0.5\n  const skip = (n: Node) => (n.parentElement?.closest(\"[aria-hidden=true]\") ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT)\n  const walk = document.createTreeWalker(box, NodeFilter.SHOW_TEXT, { acceptNode: skip })\n  const range = document.createRange()\n  for (let n = walk.nextNode() as Text | null; n; n = walk.nextNode() as Text | null) {\n    range.selectNodeContents(n)\n    const all = range.getBoundingClientRect()\n    if (!all.height || all.bottom <= edge) continue // wholly in view, or above it\n    for (const m of n.data.matchAll(/\\S+/g)) {\n      range.setStart(n, m.index)\n      range.setEnd(n, m.index + m[0].length)\n      const r = range.getBoundingClientRect()\n      if (r.bottom > edge) return { word: m[0], top: r.top }\n    }\n  }\n  return null\n}\n\n/**\n * The catchword, after the printer's: the first word of the next page set at the foot of this one. While there's more\n * below, the first word cut off by the foot waits on the rule at the far end; pressing it turns the box to that line.\n */\nfunction useCatchword(box: React.RefObject<HTMLDivElement | null>, on: boolean) {\n  React.useEffect(() => {\n    const host = box.current\n    const word = host?.querySelector<HTMLElement>(\"[data-slot=scroll-catchword]\")\n    const foot = word?.parentElement\n    if (!on || !host || !word || !foot) return\n    let frame = 0, top = 0\n    const read = () => {\n      const hit = wordBelow(host, foot)\n      word.toggleAttribute(\"data-idle\", !hit)\n      if (!hit || hit.word === word.textContent) return\n      top = hit.top\n      word.textContent = hit.word\n      word.removeAttribute(\"data-turn\") // the new word turns in (the tempo token collapses it for reduced motion)\n      void word.offsetWidth\n      word.setAttribute(\"data-turn\", \"\")\n    }\n    const soon = () => {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(read)\n    }\n    const turn = (e: PointerEvent) => {\n      if (e.button) return\n      e.preventDefault() // keep focus where it is\n      const hit = wordBelow(host, foot)\n      if (hit) top = hit.top\n      const y = host.scrollTop + top - (host.getBoundingClientRect().top + host.clientTop) - 8\n      host.scrollTo({ top: y, behavior: matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"instant\" : \"smooth\" })\n    }\n    const resize = new ResizeObserver(soon)\n    const mutation = new MutationObserver(soon)\n    resize.observe(host)\n    mutation.observe(host, { childList: true, subtree: true, characterData: true })\n    host.addEventListener(\"scroll\", soon, { passive: true })\n    word.addEventListener(\"pointerdown\", turn)\n    document.fonts.ready.then(soon)\n    read()\n    return () => {\n      cancelAnimationFrame(frame)\n      resize.disconnect()\n      mutation.disconnect()\n      host.removeEventListener(\"scroll\", soon)\n      word.removeEventListener(\"pointerdown\", turn)\n    }\n  }, [box, on])\n}\n\n/**\n * A box that scrolls. A rule appears at an edge only while there's more beyond it, and the\n * scrollbar is the page's own ruler rail: down the side, and along the bottom when the content runs sideways\n * (each rail stays hidden until there's something to scroll its way). Give it a height (or max-height) and a\n * name: it takes focus so the keyboard can scroll it, and a name says what it holds.\n * variant=\"catchword\" sets the first word waiting below on the foot rule; variant=\"wheel\" turns the children of\n * what you put inside on an arc, the one in the middle in ink.\n */\nfunction ScrollArea({ className, children, sections, variant = \"ruled\", ref: forwardedRef, ...props }: ScrollAreaProps) {\n  const named = props[\"aria-label\"] || props[\"aria-labelledby\"]\n  const box = React.useRef<HTMLDivElement>(null)\n  const composedRef = useComposedRefs(box, forwardedRef)\n  useCatchword(box, variant === \"catchword\")\n  return (\n    <div ref={composedRef} data-slot=\"scroll-area\" data-variant={variant} tabIndex={0} role={named ? \"region\" : undefined} className={cn(\"ot-scroll\", className)} {...props}>\n      {children}\n      {variant === \"catchword\" && (\n        <div className=\"ot-scroll-foot\" aria-hidden=\"true\">\n          <span data-slot=\"scroll-catchword\" data-idle=\"\" className=\"ot-scroll-catchword\" />\n        </div>\n      )}\n      <Scrollbar sections={sections} />\n      <Scrollbar axis=\"x\" sections={sections} />\n    </div>\n  )\n}\n\nexport { ScrollArea }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/scroll-area.css",
      "content": "@layer components {\n  /* ── ot-scroll ── a rule appears at an edge only while there's more beyond it */\n  .ot-scroll {\n    overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--ot-rule-strong) transparent;\n    /* The paper covering each rule scrolls with the content; the rules stay put. */\n    background:\n      linear-gradient(var(--ot-paper), var(--ot-paper)) 0 0 / 100% 2px no-repeat local,\n      linear-gradient(var(--ot-paper), var(--ot-paper)) 0 100% / 100% 2px no-repeat local,\n      linear-gradient(var(--ot-rule-strong), var(--ot-rule-strong)) 0 0 / 100% var(--ot-hairline) no-repeat scroll,\n      linear-gradient(var(--ot-rule-strong), var(--ot-rule-strong)) 0 100% / 100% var(--ot-hairline) no-repeat scroll;\n  }\n  /* The scroll area wears the page's rail: as wide, its hairline as far in, with a mark at each of its sections.\n     It holds two rails (down and along), so it is positioned itself rather than have each rail ask for it. */\n  .ot-scroll[data-slot=\"scroll-area\"] { position: relative; }\n  .ot-scroll[data-slot=\"scroll-area\"] > .ot-scrollbar { --line: 0.5rem; width: 1.5rem; }\n  .ot-scroll[data-slot=\"scroll-area\"] > .ot-scrollbar[data-axis=\"x\"] { width: auto; height: 1.5rem; }\n\n  /* Catchword, after the printer's: the first word of the next page set alone on a line at the foot of this one, so the\n     reader goes on. While there's more below, a foot line of paper rides the bottom of the box (sticky, with no script) and\n     the text passes under it; on it, at the far end and clear of the rail, waits the first word the foot cuts off, over\n     the rule. Each new word turns in as it arrives. Pressing it turns the box to that line. At the end the word and the rule go. */\n  .ot-scroll-foot {\n    position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: flex-end; align-items: end; height: 1.625rem; padding-inline-end: 1.75rem;\n    background: var(--ot-paper); pointer-events: none;\n    border-block-end: var(--ot-hairline) solid var(--ot-rule-strong); /* the rule, drawn on the foot so it sits over what passes under */\n  }\n  .ot-scroll-foot:has(> [data-idle]) { border-block-end-color: transparent; }\n  .ot-scroll-catchword {\n    padding-block-end: 0.2rem; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); letter-spacing: var(--ot-pp-tr); color: var(--ot-pencil);\n    white-space: nowrap; cursor: pointer; pointer-events: auto; transition: color var(--ot-allegro) var(--ot-exhale);\n  }\n  .ot-scroll-catchword:hover { color: var(--ot-ink); }\n  .ot-scroll-catchword[data-idle] { visibility: hidden; }\n  .ot-scroll-catchword[data-turn] { animation: ot-scroll-turn var(--ot-allegro) var(--ot-exhale); }\n  @keyframes ot-scroll-turn { from { opacity: 0; translate: 0 0.6em; } }\n\n  /* Wheel, after WOVE, where numbers ride an arc, shrinking and fading by their distance from the chosen one: the children\n     of what you put inside turn on an arc as the box scrolls. The row in the middle stands at the start edge, full size, in\n     ink; rows toward the top and bottom fall back along the curve toward the rail, a little smaller, and step back to pencil.\n     Each row runs on its own view timeline, so the wheel keeps time with the scroll and nothing moves while it rests.\n     With reduced motion only the ink changes. */\n  .ot-scroll[data-variant=\"wheel\"] { --ot-scroll-arc: var(--ot-space-7); overflow-x: hidden; padding-inline-end: var(--ot-scroll-arc); }\n  .ot-scroll[data-variant=\"wheel\"] > :first-child > * { transform-origin: left center; }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *).ot-scroll[data-variant=\"wheel\"] { --ot-scroll-dir: -1; }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *).ot-scroll[data-variant=\"wheel\"] > :first-child > * { transform-origin: right center; }\n  @supports (animation-timeline: view()) {\n    .ot-scroll[data-variant=\"wheel\"] > :first-child > * { animation: ot-scroll-wheel linear both; animation-timeline: view(); }\n    @media (prefers-reduced-motion: reduce) { .ot-scroll[data-variant=\"wheel\"] > :first-child > * { animation-name: ot-scroll-wheel-still; } }\n  }\n  /* Offset by the square of the distance d from the middle (d = 1, 0.8, 0.6, 0.4, 0.2, 0), so the curve reads across a short box. */\n  @keyframes ot-scroll-wheel {\n    0%, 100% { translate: calc(var(--ot-scroll-arc) * var(--ot-scroll-dir, 1)) 0; scale: 0.84; color: var(--ot-rule-strong); }\n    10%, 90% { translate: calc(0.64 * var(--ot-scroll-arc) * var(--ot-scroll-dir, 1)) 0; scale: 0.9; }\n    20%, 80% { translate: calc(0.36 * var(--ot-scroll-arc) * var(--ot-scroll-dir, 1)) 0; scale: 0.94; color: var(--ot-pencil); }\n    30%, 70% { translate: calc(0.16 * var(--ot-scroll-arc) * var(--ot-scroll-dir, 1)) 0; scale: 0.97; color: var(--ot-graphite); }\n    40%, 60% { translate: calc(0.04 * var(--ot-scroll-arc) * var(--ot-scroll-dir, 1)) 0; scale: 0.99; }\n    50% { translate: 0 0; scale: 1; color: var(--ot-ink); }\n  }\n  @keyframes ot-scroll-wheel-still {\n    0%, 100% { color: var(--ot-rule-strong); }\n    20%, 80% { color: var(--ot-pencil); }\n    30%, 70% { color: var(--ot-graphite); }\n    50% { color: var(--ot-ink); }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/scroll-area.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/scroll-area.css\"": {}
  },
  "meta": {
    "movement": "IX",
    "underneath": "native",
    "contract": "ot-scroll"
  },
  "type": "registry:ui"
}
