{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-scroll","type":"registry:component","title":"Jump to latest","description":"A chat that follows the answer as it streams, lets go the moment you scroll up, and brings you back.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/chat-scroll.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDown } from \"lucide-react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * JUMP TO LATEST: a chat that follows the answer, until you\n * scroll away\n *\n *   following  pinned to the bottom while text streams in\n *   away       you scrolled up: it lets go at once, and a round\n *              ↓ button appears\n *   new below  text arrived while you were away: the button\n *              widens into \"● Writing\"; when the answer ends the\n *              label morphs to \"New reply\"\n *   back       a click glides down, chasing the moving bottom,\n *              and it follows again\n *\n * Wrap your messages. Give it a height (or flex-1) and change\n * followKey when someone sends, to bring them back down.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ChatScrollProps extends React.HTMLAttributes<HTMLDivElement> {\n  children: React.ReactNode;\n  /** True while an answer streams; the button says \"Writing\". */\n  streaming?: boolean;\n  /** Change it (e.g. to the latest message id) to jump to the bottom, e.g. after sending. */\n  followKey?: React.Key;\n  /** How close to the bottom (px) still counts as at the bottom. */\n  threshold?: number;\n  /** Classes for the inner content column. */\n  contentClassName?: string;\n}\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nexport function ChatScroll({\n  children,\n  streaming = false,\n  followKey,\n  threshold = 48,\n  className = \"\",\n  contentClassName = \"\",\n  ...props\n}: ChatScrollProps) {\n  const reduced = useReducedMotion();\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const stuck = React.useRef(true);\n  const lastTop = React.useRef(0);\n  const touchY = React.useRef(0);\n  const frame = React.useRef(0);\n  const gliding = React.useRef(false);\n  // When the person last reached for the scroll; only their own scrolling lets go of the bottom.\n  const intent = React.useRef(0);\n  const [away, setAway] = React.useState(false);\n  const [unread, setUnread] = React.useState(false);\n\n  const distance = () => {\n    const el = scrollerRef.current;\n    return el ? el.scrollHeight - el.clientHeight - el.scrollTop : 0;\n  };\n\n  const release = () => {\n    cancelAnimationFrame(frame.current);\n    gliding.current = false;\n    stuck.current = false;\n  };\n\n  /* Glide to the bottom, re-reading it every frame because it moves while text streams in. */\n  const toBottom = React.useCallback(\n    (smooth: boolean) => {\n      const el = scrollerRef.current;\n      if (!el) return;\n      cancelAnimationFrame(frame.current);\n      stuck.current = true;\n      setAway(false);\n      setUnread(false);\n      if (!smooth || reduced) {\n        el.scrollTop = el.scrollHeight;\n        return;\n      }\n      gliding.current = true;\n      const from = el.scrollTop;\n      const start = performance.now();\n      const step = (now: number) => {\n        const t = Math.min(1, (now - start) / 520);\n        const eased = 1 - Math.pow(1 - t, 4);\n        el.scrollTop = from + (el.scrollHeight - el.clientHeight - from) * eased;\n        if (t < 1) frame.current = requestAnimationFrame(step);\n        else gliding.current = false;\n      };\n      frame.current = requestAnimationFrame(step);\n    },\n    [reduced]\n  );\n\n  // Start at the latest message.\n  React.useLayoutEffect(() => {\n    const el = scrollerRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, []);\n\n  // While following, every bit of new content keeps the bottom in view; while away, it's unread.\n  React.useEffect(() => {\n    const content = contentRef.current;\n    const el = scrollerRef.current;\n    if (!content || !el) return;\n    const observer = new ResizeObserver(() => {\n      if (stuck.current) {\n        if (!gliding.current) el.scrollTop = el.scrollHeight;\n      } else if (distance() > threshold) {\n        setUnread(true);\n        setAway(true);\n      }\n    });\n    observer.observe(content);\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame.current);\n    };\n  }, [threshold]);\n\n  // A new message from the person brings them back down.\n  const firstKey = React.useRef(followKey);\n  React.useEffect(() => {\n    if (followKey === firstKey.current) return;\n    firstKey.current = followKey;\n    toBottom(true);\n  }, [followKey, toBottom]);\n\n  const onScroll = () => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    const top = el.scrollTop;\n    const d = distance();\n    // Upward movement counts only if the person caused it, not when content shrinks and the browser clamps.\n    const userUp = top < lastTop.current - 1 && !gliding.current && performance.now() - intent.current < 800;\n    lastTop.current = top;\n    if (userUp) {\n      release();\n      setAway(d > threshold);\n    } else if (!stuck.current && d <= threshold) {\n      // Back at the bottom: follow again.\n      stuck.current = true;\n      setAway(false);\n      setUnread(false);\n    } else if (!stuck.current) setAway(d > threshold);\n  };\n\n  const reach = () => (intent.current = performance.now());\n\n  return (\n    <div className={`relative min-h-0 ${className}`} {...props}>\n      <div\n        ref={scrollerRef}\n        tabIndex={0}\n        aria-label=\"Conversation\"\n        onScroll={onScroll}\n        // Let go the moment someone reaches for the scroll, before the next frame of text pulls them down.\n        onWheel={(e) => {\n          reach();\n          if (e.deltaY < 0) release();\n        }}\n        onTouchStart={(e) => {\n          reach();\n          touchY.current = e.touches[0]?.clientY ?? 0;\n        }}\n        onTouchMove={(e) => {\n          reach();\n          if ((e.touches[0]?.clientY ?? 0) > touchY.current) release();\n        }}\n        onKeyDown={(e) => {\n          reach();\n          if ([\"ArrowUp\", \"PageUp\", \"Home\"].includes(e.key)) release();\n        }}\n        // Dragging the scrollbar.\n        onPointerDown={reach}\n        className=\"h-full overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/30\"\n      >\n        <div ref={contentRef} className={contentClassName}>\n          {children}\n        </div>\n      </div>\n\n      <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n        <button\n          type=\"button\"\n          inert={!away}\n          onClick={() => toBottom(true)}\n          aria-label={unread ? `${streaming ? \"Still writing\" : \"New reply\"} below. Jump to latest` : \"Jump to latest\"}\n          className=\"pointer-events-auto flex h-8 items-center rounded-full border border-border bg-background px-2 text-[12.5px] font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 active:scale-[0.96]\"\n          style={{\n            opacity: away ? 1 : 0,\n            transform: away ? \"none\" : \"translateY(8px) scale(0.92)\",\n            transition: reduced ? \"none\" : `opacity 220ms ${EASE}, transform 320ms ${EASE}`,\n          }}\n        >\n          {/* The label opens out of the round button; its words morph from \"Writing\" to \"New reply\" and the pill follows. */}\n          <span\n            aria-hidden=\"true\"\n            className=\"grid\"\n            style={{\n              gridTemplateColumns: unread ? \"1fr\" : \"0fr\",\n              opacity: unread ? 1 : 0,\n              transition: reduced ? \"none\" : `grid-template-columns 380ms ${EASE}, opacity 260ms ${EASE}`,\n            }}\n          >\n            <span className=\"flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap\">\n              <span className=\"flex shrink-0 items-center gap-1.5 pl-1 pr-1.5\">\n                <span className=\"relative flex size-1.5\">\n                  <span className={`absolute inset-0 rounded-full bg-primary motion-reduce:animate-none ${streaming ? \"animate-[ui-ping_1.4s_cubic-bezier(0,0,0.2,1)_infinite]\" : \"opacity-0\"}`} />\n                  <span className=\"relative size-1.5 rounded-full bg-primary\" />\n                </span>\n                <span className={`transition-colors duration-300 ${streaming ? SHEEN : \"\"}`}>\n                  <TextMorph>{streaming ? \"Writing\" : \"New reply\"}</TextMorph>\n                </span>\n              </span>\n            </span>\n          </span>\n          <ArrowDown aria-hidden=\"true\" className=\"size-4\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-ping":{"from":{"transform":"scale(1)","opacity":"0.55"},"to":{"transform":"scale(2.6)","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/chat-scroll"}