{
  "schemaVersion": 1,
  "name": "message-scroller",
  "useFor": "Use to hold a conversation that grows: MessageScroller with MessageScrollerViewport, MessageScrollerContent and one MessageScrollerItem per message, plus MessageScrollerButton. Set scrollAnchor on the message just sent. Wrap the composer in MessageScrollerProvider when its send key needs useMessageScroller.",
  "constraints": [
    "Scrolling the page instead of the conversation",
    "Sliding the jump key in",
    "Auto-scrolling while the reader is reading older messages"
  ],
  "documentation": "",
  "source": "Extrapolated from opencommerce (the secondary key and floating drop)",
  "exports": [
    "MessageScroller",
    "MessageScrollerButton",
    "MessageScrollerContent",
    "MessageScrollerItem",
    "MessageScrollerProvider",
    "MessageScrollerViewport",
    "useMessageScroller",
    "useMessageScrollerScrollable",
    "useMessageScrollerVisibility"
  ],
  "client": true,
  "docsUrl": "https://opencommerce-ds.vercel.app/components/message-scroller",
  "markdownUrl": "https://opencommerce-ds.vercel.app/r/message-scroller.md",
  "examples": [
    {
      "path": "examples/message-scroller.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { SendHorizontal } from \"lucide-react\";\n\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Marker, MarkerContent } from \"@/components/ui/marker\";\nimport { Message, MessageContent, MessageHeader } from \"@/components/ui/message\";\nimport { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller } from \"@/components/ui/message-scroller\";\nimport { Window } from \"@/components/ui/window\";\n\ntype Entry = { id: number; from: \"person\" | \"agent\"; text: string };\n\nconst STORES = [\"Amazon\", \"Target\", \"Best Buy\", \"Staples\", \"Wayfair\"];\nconst START: Entry[] = Array.from({ length: 14 }, (_, i) => ({\n  id: i,\n  from: i % 3 === 2 ? \"agent\" : \"person\",\n  text: i % 3 === 2 ? `Checked ${STORES[i % STORES.length]}: in stock, ships tomorrow.` : `Can you price the standing desk at ${STORES[i % STORES.length]}?`,\n}));\n\nfunction Composer({ onSend }: { onSend: (text: string) => void }) {\n  const { scrollToEnd } = useMessageScroller();\n  const [text, setText] = React.useState(\"\");\n  return (\n    <form\n      className=\"flex items-center gap-2 border-t border-line p-3\"\n      onSubmit={(event) => {\n        event.preventDefault();\n        if (!text.trim()) return;\n        onSend(text.trim());\n        setText(\"\");\n        scrollToEnd({ behavior: \"auto\" });\n      }}\n    >\n      <Input aria-label=\"Message\" value={text} onChange={(e) => setText(e.target.value)} placeholder=\"Ask your agent to buy something\" />\n      <Button type=\"submit\" size=\"icon\" className=\"size-10\" aria-label=\"Send\">\n        <SendHorizontal strokeWidth={1.75} />\n      </Button>\n    </form>\n  );\n}\n\nexport function Site() {\n  const [entries, setEntries] = React.useState(START);\n  return (\n    <Window name=\"agent.chat\" className=\"mx-auto h-110 w-full max-w-xl\" bodyClassName=\"flex min-h-0 flex-col\">\n      <MessageScrollerProvider>\n        <MessageScroller className=\"min-h-0 flex-1\">\n          <MessageScrollerViewport>\n            <MessageScrollerContent className=\"gap-4\">\n              <Marker variant=\"separator\">\n                <MarkerContent>Yesterday</MarkerContent>\n              </Marker>\n              {entries.map((entry) => (\n                <MessageScrollerItem key={entry.id}>\n                  <Message align={entry.from === \"agent\" ? \"end\" : \"start\"}>\n                    <MessageContent>\n                      <MessageHeader>{entry.from === \"agent\" ? \"Shopping agent\" : \"Jordan Miles\"}</MessageHeader>\n                      <Bubble variant={entry.from === \"agent\" ? \"default\" : \"secondary\"} align={entry.from === \"agent\" ? \"end\" : \"start\"}>\n                        <BubbleContent>{entry.text}</BubbleContent>\n                      </Bubble>\n                    </MessageContent>\n                  </Message>\n                </MessageScrollerItem>\n              ))}\n            </MessageScrollerContent>\n          </MessageScrollerViewport>\n          <MessageScrollerButton />\n        </MessageScroller>\n        <Composer onSend={(text) => setEntries((all) => [...all, { id: all.length, from: \"person\", text }])} />\n      </MessageScrollerProvider>\n    </Window>\n  );\n}\n\nexport function Streaming() {\n  const [count, setCount] = React.useState(3);\n  React.useEffect(() => {\n    if (count >= 12) return;\n    // ds-latency: simulates order updates arriving in this example; scrolling and input remain immediate.\n    const t = setTimeout(() => setCount((c) => c + 1), 900);\n    return () => clearTimeout(t);\n  }, [count]);\n  return (\n    <Window name=\"orders.live\" className=\"mx-auto h-80 w-full max-w-xl\" bodyClassName=\"flex min-h-0 flex-col\">\n      <MessageScroller>\n        <MessageScrollerViewport>\n          <MessageScrollerContent className=\"gap-2\">\n            {Array.from({ length: count }, (_, i) => (\n              <MessageScrollerItem key={i}>\n                <Bubble variant=\"muted\">\n                  <BubbleContent>{`Update ${i + 1}: order #A-${10293 + i} moved to ${i % 2 ? \"shipped\" : \"confirmed\"}.`}</BubbleContent>\n                </Bubble>\n              </MessageScrollerItem>\n            ))}\n          </MessageScrollerContent>\n        </MessageScrollerViewport>\n        <MessageScrollerButton direction=\"start\" />\n        <MessageScrollerButton />\n      </MessageScroller>\n    </Window>\n  );\n}\n",
      "exports": [
        "Site",
        "Streaming"
      ],
      "requires": [
        "message-scroller",
        "bubble",
        "button",
        "input",
        "marker",
        "message",
        "window"
      ],
      "packages": [
        "lucide-react"
      ],
      "previews": [
        {
          "name": "Site",
          "url": "https://opencommerce-ds.vercel.app/preview/message-scroller/Site"
        },
        {
          "name": "Streaming",
          "url": "https://opencommerce-ds.vercel.app/preview/message-scroller/Streaming"
        }
      ]
    }
  ],
  "type": "ui",
  "title": "Message Scroller",
  "description": "A conversation's scroll area that stays on the newest message and offers the white Jump to latest key when the reader scrolls up.",
  "surface": "site",
  "kinds": [],
  "requires": [
    "theme",
    "button",
    "utils"
  ],
  "packages": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "components/ui/message-scroller.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDown, ArrowUp } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\n// A conversation's scroll area. It opens at the newest message and stays pinned there while new messages arrive, until\n// the reader scrolls up; then the white key (the site's secondary key on the hairline, 34px, with the floating drop)\n// says \"Jump to latest\" at the bottom center. It only fades (200ms, the strong ease-out): it shows while someone is\n// reading, and sliding would pull the eye off the text. The jump scrolls smoothly, or instantly with reduced motion.\n\ntype ScrollOptions = { behavior?: ScrollBehavior };\n\ntype ScrollerState = {\n  isAtStart: boolean;\n  isAtEnd: boolean;\n  scrollable: boolean;\n  viewport: HTMLDivElement | null;\n  setViewport: (el: HTMLDivElement | null) => void;\n  setContent: (el: HTMLDivElement | null) => void;\n  update: () => void;\n  scrollToEnd: (options?: ScrollOptions) => void;\n  scrollToStart: (options?: ScrollOptions) => void;\n};\n\nconst ScrollerContext = React.createContext<ScrollerState | null>(null);\n\n// How close to the end still counts as reading the newest message.\nconst END_SLACK = 24;\n\nfunction smoothUnlessReduced(): ScrollBehavior {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\";\n}\n\n/** Holds a scroller's state, so controls outside the scroll area (a composer's send button) can reach it. */\nfunction MessageScrollerProvider({ children }: { children: React.ReactNode }) {\n  const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null);\n  const [content, setContent] = React.useState<HTMLDivElement | null>(null);\n  const [edges, setEdges] = React.useState({ isAtStart: true, isAtEnd: true, scrollable: false });\n  // Pinned while the reader is at the newest message; new content then keeps the view at the end.\n  const pinned = React.useRef(true);\n\n  const update = React.useCallback(() => {\n    if (!viewport) return;\n    const { scrollTop, scrollHeight, clientHeight } = viewport;\n    const isAtEnd = scrollHeight - scrollTop - clientHeight <= END_SLACK;\n    const next = { isAtStart: scrollTop <= 1, isAtEnd, scrollable: scrollHeight > clientHeight + 1 };\n    pinned.current = isAtEnd;\n    setEdges((prev) => (prev.isAtStart === next.isAtStart && prev.isAtEnd === next.isAtEnd && prev.scrollable === next.scrollable ? prev : next));\n  }, [viewport]);\n\n  const scrollToEnd = React.useCallback(\n    (options?: ScrollOptions) => {\n      if (!viewport) return;\n      pinned.current = true;\n      viewport.scrollTo({ top: viewport.scrollHeight, behavior: options?.behavior ?? smoothUnlessReduced() });\n    },\n    [viewport],\n  );\n\n  const scrollToStart = React.useCallback(\n    (options?: ScrollOptions) => {\n      if (!viewport) return;\n      pinned.current = false;\n      viewport.scrollTo({ top: 0, behavior: options?.behavior ?? smoothUnlessReduced() });\n    },\n    [viewport],\n  );\n\n  // Open at the newest message.\n  React.useLayoutEffect(() => {\n    if (!viewport) return;\n    viewport.scrollTop = viewport.scrollHeight;\n    update();\n  }, [viewport, update]);\n\n  // Keep the newest message in view as content grows, unless the reader has scrolled away.\n  React.useEffect(() => {\n    if (!viewport || !content) return;\n    const observer = new ResizeObserver(() => {\n      if (pinned.current) viewport.scrollTop = viewport.scrollHeight;\n      update();\n    });\n    observer.observe(content);\n    observer.observe(viewport);\n    return () => observer.disconnect();\n  }, [viewport, content, update]);\n\n  const value = React.useMemo<ScrollerState>(() => ({ ...edges, viewport, setViewport, setContent, update, scrollToEnd, scrollToStart }), [edges, viewport, update, scrollToEnd, scrollToStart]);\n  return <ScrollerContext.Provider value={value}>{children}</ScrollerContext.Provider>;\n}\n\nfunction useScrollerContext(part: string) {\n  const context = React.useContext(ScrollerContext);\n  if (!context) throw new Error(`${part} must be used inside MessageScroller.`);\n  return context;\n}\n\n/** The scroller's position and controls: whether it is at the start or the end, and functions to jump there. */\nfunction useMessageScroller() {\n  const { isAtStart, isAtEnd, scrollToEnd, scrollToStart } = useScrollerContext(\"useMessageScroller\");\n  return { isAtStart, isAtEnd, scrollToEnd, scrollToStart };\n}\n\n/** Whether there is more to scroll to in each direction. */\nfunction useMessageScrollerScrollable() {\n  const { isAtStart, isAtEnd, scrollable } = useScrollerContext(\"useMessageScrollerScrollable\");\n  return { scrollable, canScrollStart: scrollable && !isAtStart, canScrollEnd: scrollable && !isAtEnd };\n}\n\n/** Whether an element (an unread marker, a message) is in view inside the scroller. */\nfunction useMessageScrollerVisibility(ref: React.RefObject<Element | null>, threshold = 0.5) {\n  const { viewport } = useScrollerContext(\"useMessageScrollerVisibility\");\n  const [visible, setVisible] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!viewport || !el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { root: viewport, threshold });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [viewport, ref, threshold]);\n  return visible;\n}\n\nfunction ScrollerRoot({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"message-scroller\" className={cn(\"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden\", className)} {...props} />;\n}\n\nfunction MessageScroller(props: React.ComponentProps<\"div\">) {\n  const parent = React.useContext(ScrollerContext);\n  if (parent) return <ScrollerRoot {...props} />;\n  return (\n    <MessageScrollerProvider>\n      <ScrollerRoot {...props} />\n    </MessageScrollerProvider>\n  );\n}\n\nfunction MessageScrollerViewport({ className, onScroll, ...props }: React.ComponentProps<\"div\">) {\n  const { setViewport, update } = useScrollerContext(\"MessageScrollerViewport\");\n  return (\n    <div\n      ref={setViewport}\n      data-slot=\"message-scroller-viewport\"\n      tabIndex={0}\n      role=\"log\"\n      aria-live=\"polite\"\n      onScroll={(event) => {\n        update();\n        onScroll?.(event);\n      }}\n      className={cn(\"size-full min-h-0 min-w-0 overflow-y-auto overscroll-contain outline-none [scrollbar-gutter:stable] [scrollbar-width:thin] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction MessageScrollerContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { setContent } = useScrollerContext(\"MessageScrollerContent\");\n  return <div ref={setContent} data-slot=\"message-scroller-content\" className={cn(\"flex h-max min-h-full flex-col justify-end gap-6 p-4\", className)} {...props} />;\n}\n\n/**\n * One entry of the conversation. `scrollAnchor` brings it to the top of the view when it mounts (the message just\n * sent, so its reply grows below it).\n */\nfunction MessageScrollerItem({ className, scrollAnchor = false, ...props }: React.ComponentProps<\"div\"> & { scrollAnchor?: boolean }) {\n  const { viewport } = useScrollerContext(\"MessageScrollerItem\");\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!scrollAnchor || !viewport || !el) return;\n    viewport.scrollTop += el.getBoundingClientRect().top - viewport.getBoundingClientRect().top;\n  }, [scrollAnchor, viewport]);\n  return <div ref={ref} data-slot=\"message-scroller-item\" data-scroll-anchor={scrollAnchor || undefined} className={cn(\"min-w-0 shrink-0\", className)} {...props} />;\n}\n\nfunction MessageScrollerButton({ direction = \"end\", className, children, variant = \"secondary\", size = \"sm\", onClick, ...props }: React.ComponentProps<typeof Button> & { direction?: \"start\" | \"end\" }) {\n  const { isAtStart, isAtEnd, scrollable, scrollToEnd, scrollToStart } = useScrollerContext(\"MessageScrollerButton\");\n  const active = scrollable && (direction === \"end\" ? !isAtEnd : !isAtStart);\n  return (\n    <Button\n      data-slot=\"message-scroller-button\"\n      data-direction={direction}\n      data-active={active}\n      aria-hidden={!active || undefined}\n      tabIndex={active ? undefined : -1}\n      variant={variant}\n      size={size}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) (direction === \"end\" ? scrollToEnd : scrollToStart)();\n      }}\n      className={cn(\n        \"absolute left-1/2 z-10 -translate-x-1/2 gap-1.5 shadow-floating transition-[opacity,background-color,scale] duration-200 ease-out data-[active=false]:pointer-events-none data-[active=false]:opacity-0 data-[direction=end]:bottom-4 data-[direction=start]:top-4 motion-reduce:transition-[opacity,background-color] [&_svg:not([class*='size-'])]:size-3.5\",\n        className,\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          {direction === \"end\" ? <ArrowDown strokeWidth={1.75} /> : <ArrowUp strokeWidth={1.75} />}\n          {direction === \"end\" ? \"Jump to latest\" : \"Jump to first\"}\n        </>\n      )}\n    </Button>\n  );\n}\n\nexport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n  useMessageScroller,\n  useMessageScrollerScrollable,\n  useMessageScrollerVisibility,\n};\n"
    }
  ]
}
