{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-scroller",
  "title": "Message scroller",
  "description": "A chat scroll area that follows new messages, keeps your place while you read back, and counts what you missed on the jump button.",
  "dependencies": [
    "@shadcn/react@0.3.1",
    "@tabler/icons-react",
    "cn"
  ],
  "registryDependencies": [
    "https://hextaui.com/r/theme.json",
    "https://hextaui.com/r/button.json",
    "https://hextaui.com/r/motion.json",
    "https://hextaui.com/r/message.json"
  ],
  "files": [
    {
      "path": "components/ui/message-scroller.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  MessageScroller as MessageScrollerPrimitive,\n  useMessageScroller,\n  useMessageScrollerScrollable,\n  useMessageScrollerVisibility,\n} from \"@shadcn/react/message-scroller\"\nimport { IconArrowDown } from \"@tabler/icons-react\"\nimport { cn } from \"cn\"\n\nimport { useSizeMorph } from \"@/lib/motion\"\n\nimport { Button } from \"@/components/ui/button\"\n\nfunction MessageScrollerProvider(\n  props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>\n) {\n  return <MessageScrollerPrimitive.Provider {...props} />\n}\n\nfunction MessageScroller({\n  className,\n  ...props\n}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {\n  return (\n    <MessageScrollerPrimitive.Root\n      data-slot=\"message-scroller\"\n      className={cn(\n        \"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction MessageScrollerViewport({\n  className,\n  ...props\n}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {\n  return (\n    <MessageScrollerPrimitive.Viewport\n      data-slot=\"message-scroller-viewport\"\n      className={cn(\n        \"size-full min-h-0 min-w-0 [scrollbar-width:thin] [scrollbar-color:var(--color-border)_transparent] [scrollbar-gutter:stable] overflow-y-auto overscroll-none mask-[linear-gradient(to_bottom,transparent,#000_var(--scroller-fade-start),#000_calc(100%-var(--scroller-fade-end)),transparent)] transition-[--scroller-fade-start,--scroller-fade-end] duration-200 ease-out-cubic [contain:content] outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden focus-visible:ring-inset data-autoscrolling:[scrollbar-color:transparent_transparent] data-pending-scroll:invisible data-[scrollable~=end]:[--scroller-fade-end:--spacing(10)] data-[scrollable~=start]:[--scroller-fade-start:--spacing(10)] motion-reduce:transition-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst MessageScrollerReadyContext = React.createContext(false)\n\nfunction useSpacerHold(contentRef: React.RefObject<HTMLDivElement | null>) {\n  React.useLayoutEffect(() => {\n    const content = contentRef.current\n    const viewport = content?.parentElement\n    const spacer = content?.querySelector<HTMLElement>(\n      \"[data-message-scroller-spacer]\"\n    )\n    if (\n      !content ||\n      !viewport ||\n      !spacer ||\n      typeof ResizeObserver === \"undefined\"\n    ) {\n      return\n    }\n\n    let lastScrollTop = viewport.scrollTop\n    let spacerHeight = spacer.getBoundingClientRect().height\n    let held = Infinity\n    let collapsed = false\n    let reading = false\n\n    const naturalHeight = () => {\n      const rows = Array.from(content.children).filter(\n        (child): child is HTMLElement =>\n          child instanceof HTMLElement && child !== spacer\n      )\n      const last = rows.at(-1)\n      const padding = parseFloat(getComputedStyle(content).paddingBottom) || 0\n      return last ? last.offsetTop + last.offsetHeight + padding : 0\n    }\n\n    const release = () => {\n      collapsed = false\n      if (held !== Infinity) {\n        held = Infinity\n        content.style.minHeight = \"\"\n      }\n    }\n\n    const hold = (scrollTop: number) => {\n      const floor = Math.min(\n        held,\n        scrollTop + viewport.clientHeight - content.offsetTop\n      )\n      if (floor <= Math.max(viewport.clientHeight, naturalHeight()) + 0.5) {\n        release()\n        return\n      }\n      held = floor\n      content.style.minHeight = `${floor}px`\n    }\n\n    const onScroll = () => {\n      lastScrollTop = viewport.scrollTop\n      if (collapsed && held !== Infinity) {\n        hold(lastScrollTop)\n      }\n    }\n\n    const onUserScroll = () => {\n      reading = true\n      if (!collapsed) {\n        release()\n      }\n    }\n\n    const onSpacer = () => {\n      const next = spacer.getBoundingClientRect().height\n      const shrank = next < spacerHeight - 0.5\n      spacerHeight = next\n      if (next > 0.5) {\n        collapsed = false\n        if (reading) {\n          return\n        }\n        content.style.minHeight = \"\"\n        held = content.getBoundingClientRect().height\n        content.style.minHeight = `${held}px`\n        return\n      }\n      if (held !== Infinity) {\n        collapsed = true\n        hold(viewport.scrollTop)\n        return\n      }\n      if (shrank) {\n        const keep = lastScrollTop\n        collapsed = true\n        hold(keep)\n        viewport.scrollTop = keep\n      }\n    }\n\n    const onRows = () => {\n      reading = false\n      release()\n    }\n\n    const spacerObserver = new ResizeObserver(onSpacer)\n    spacerObserver.observe(spacer)\n    const rows = new MutationObserver(onRows)\n    rows.observe(content, { childList: true })\n    viewport.addEventListener(\"scroll\", onScroll, { passive: true })\n    const intents = [\"wheel\", \"touchstart\", \"keydown\", \"pointerdown\"] as const\n    for (const type of intents) {\n      viewport.addEventListener(type, onUserScroll, { passive: true })\n    }\n\n    return () => {\n      spacerObserver.disconnect()\n      rows.disconnect()\n      viewport.removeEventListener(\"scroll\", onScroll)\n      for (const type of intents) {\n        viewport.removeEventListener(type, onUserScroll)\n      }\n      content.style.minHeight = \"\"\n    }\n  }, [contentRef])\n}\n\nfunction MessageScrollerContent({\n  className,\n  ref,\n  ...props\n}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {\n  const [ready, setReady] = React.useState(false)\n  const contentRef = React.useRef<HTMLDivElement | null>(null)\n  useSpacerHold(contentRef)\n  const setRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      contentRef.current = node\n      if (typeof ref === \"function\") {\n        return ref(node)\n      }\n      if (ref) {\n        ref.current = node\n      }\n      return undefined\n    },\n    [ref]\n  )\n\n  React.useEffect(() => {\n    setReady(true)\n  }, [])\n\n  return (\n    <MessageScrollerReadyContext.Provider value={ready}>\n      <MessageScrollerPrimitive.Content\n        ref={setRef}\n        data-slot=\"message-scroller-content\"\n        className={cn(\"flex h-max min-h-full flex-col gap-4 p-4\", className)}\n        {...props}\n      />\n    </MessageScrollerReadyContext.Provider>\n  )\n}\n\ntype MessageScrollerItemProps = React.ComponentProps<\n  typeof MessageScrollerPrimitive.Item\n> & {\n  animated?: boolean\n}\n\nfunction MessageScrollerItem({\n  className,\n  scrollAnchor = false,\n  animated = true,\n  ...props\n}: MessageScrollerItemProps) {\n  const ready = React.useContext(MessageScrollerReadyContext)\n  const [entering] = React.useState(() => animated && ready)\n\n  return (\n    <MessageScrollerPrimitive.Item\n      data-slot=\"message-scroller-item\"\n      data-entering={entering ? \"\" : undefined}\n      scrollAnchor={scrollAnchor}\n      className={cn(\n        \"min-w-0 shrink-0 [contain-intrinsic-size:auto_6rem] [content-visibility:auto] data-entering:motion-safe:animate-message-in motion-reduce:data-entering:animate-in motion-reduce:data-entering:fade-in-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction useUnseenCount(enabled: boolean) {\n  const anchorRef = React.useRef<HTMLButtonElement | null>(null)\n  const { visibleMessageIds } = useMessageScrollerVisibility()\n  const { end } = useMessageScrollerScrollable()\n  const seenRef = React.useRef<Set<string> | null>(null)\n  const [unseen, setUnseen] = React.useState(0)\n\n  React.useEffect(() => {\n    if (!enabled) {\n      return\n    }\n    const root = anchorRef.current?.closest(\"[data-slot=message-scroller]\")\n    if (!root) {\n      return\n    }\n    const ids = () =>\n      Array.from(\n        root.querySelectorAll<HTMLElement>(\"[data-message-id]\"),\n        (node) => node.dataset.messageId ?? \"\"\n      ).filter(Boolean)\n\n    if (!seenRef.current) {\n      seenRef.current = new Set(ids())\n    }\n    const seen = seenRef.current\n\n    const count = () => {\n      if (!end) {\n        for (const id of ids()) {\n          seen.add(id)\n        }\n        setUnseen(0)\n        return\n      }\n      for (const id of visibleMessageIds) {\n        seen.add(id)\n      }\n      setUnseen(ids().filter((id) => !seen.has(id)).length)\n    }\n\n    count()\n    const content = root.querySelector(\"[data-slot=message-scroller-content]\")\n    if (!content) {\n      return\n    }\n    const observer = new MutationObserver(count)\n    observer.observe(content, { childList: true })\n    return () => observer.disconnect()\n  }, [enabled, end, visibleMessageIds])\n\n  return { anchorRef, unseen }\n}\n\nfunction defaultUnseenLabel(count: number) {\n  return count === 1 ? \"1 new message\" : `${count} new messages`\n}\n\ntype MessageScrollerButtonProps = React.ComponentProps<\n  typeof MessageScrollerPrimitive.Button\n> &\n  Pick<React.ComponentProps<typeof Button>, \"variant\"> & {\n    showUnseen?: boolean\n    unseenLabel?: (count: number) => React.ReactNode\n  }\n\nfunction MessageScrollerButton({\n  direction = \"end\",\n  className,\n  children,\n  render,\n  variant = \"outline\",\n  showUnseen = true,\n  unseenLabel = defaultUnseenLabel,\n  ref,\n  ...props\n}: MessageScrollerButtonProps) {\n  const counting = showUnseen && direction === \"end\" && children === undefined\n  const { anchorRef, unseen } = useUnseenCount(counting)\n  const morphRef = useSizeMorph<HTMLButtonElement>({\n    axis: \"width\",\n    duration: 260,\n  })\n  const setRef = React.useCallback(\n    (node: HTMLButtonElement | null) => {\n      anchorRef.current = node\n      const cleanup = morphRef(node)\n      if (typeof ref === \"function\") {\n        ref(node)\n      } else if (ref) {\n        ref.current = node\n      }\n      return () => {\n        cleanup?.()\n        anchorRef.current = null\n      }\n    },\n    [anchorRef, morphRef, ref]\n  )\n  const label = counting && unseen > 0 ? unseenLabel(unseen) : null\n\n  return (\n    <MessageScrollerPrimitive.Button\n      ref={setRef}\n      data-slot=\"message-scroller-button\"\n      data-direction={direction}\n      data-unseen={label ? unseen : undefined}\n      direction={direction}\n      className={cn(\n        \"absolute start-1/2 z-10 -translate-x-1/2 overflow-hidden rounded-full shadow-md transition-[translate,scale,opacity] duration-200 data-morphing:overflow-clip data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-300 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-out-quint data-[direction=end]:bottom-3 data-[direction=end]:data-[active=false]:translate-y-2 data-[direction=start]:top-3 data-[direction=start]:data-[active=false]:-translate-y-2 motion-reduce:transition-opacity rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180\",\n        className\n      )}\n      render={\n        render ?? (\n          <Button\n            variant={variant}\n            size={label ? \"sm\" : \"icon-sm\"}\n            className={label ? \"ps-2.5 pe-3\" : undefined}\n          />\n        )\n      }\n      {...props}\n    >\n      {children ?? (\n        <>\n          <IconArrowDown />\n          {label ? (\n            <span className=\"whitespace-nowrap\">{label}</span>\n          ) : (\n            <span className=\"sr-only\">\n              {direction === \"end\" ? \"Scroll to latest\" : \"Scroll to start\"}\n            </span>\n          )}\n        </>\n      )}\n    </MessageScrollerPrimitive.Button>\n  )\n}\n\nexport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n  useMessageScroller,\n  useMessageScrollerScrollable,\n  useMessageScrollerVisibility,\n}\nexport type { MessageScrollerButtonProps, MessageScrollerItemProps }\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "@property --scroller-fade-start": {
      "syntax": "\"<length>\"",
      "inherits": "false",
      "initial-value": "0px"
    },
    "@property --scroller-fade-end": {
      "syntax": "\"<length>\"",
      "inherits": "false",
      "initial-value": "0px"
    }
  },
  "categories": [
    "chat"
  ],
  "type": "registry:ui"
}