{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-chat",
  "title": "AI Chat",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.",
  "dependencies": [
    "lucide-react",
    "motion@^12"
  ],
  "registryDependencies": [
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/motion-presets.json",
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/ai-chat.tsx",
      "content": "// Ballmac UI: AI Chat. https://ui.ballmac.com/components/ai-chat\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { spring } from \"@/lib/ballmac/motion\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\n/** Pixels from the bottom that still count as \"at the bottom\". */\nconst BOTTOM_THRESHOLD = 48\n\ntype ChatScroll = {\n  /** Callback ref for the scroll container. */\n  scrollRef: (node: HTMLDivElement | null) => void\n  /** Callback ref for the element whose height grows as content streams in. */\n  contentRef: (node: HTMLDivElement | null) => void\n  /** Whether the view is pinned to the newest content. */\n  isAtBottom: boolean\n  /** Scroll to the newest content and stick there while it grows. */\n  scrollToBottom: (behavior?: ScrollBehavior) => void\n}\n\nfunction useStickToBottom(): ChatScroll {\n  const [scroller, setScroller] = React.useState<HTMLDivElement | null>(null)\n  const [content, setContent] = React.useState<HTMLDivElement | null>(null)\n  const stick = React.useRef(true)\n  const [isAtBottom, setIsAtBottom] = React.useState(true)\n  const reduceMotion = useReducedMotion()\n\n  const scrollToBottom = React.useCallback(\n    (behavior: ScrollBehavior = reduceMotion ? \"auto\" : \"smooth\") => {\n      stick.current = true\n      setIsAtBottom(true)\n      scroller?.scrollTo({ top: scroller.scrollHeight, behavior })\n    },\n    [reduceMotion, scroller]\n  )\n\n  React.useEffect(() => {\n    if (!scroller) return\n    let lastTop = scroller.scrollTop\n    const onScroll = () => {\n      const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight\n      const atBottom = distance <= BOTTOM_THRESHOLD\n      // Only an upward scroll releases the pin; smooth scrolls towards the bottom keep it.\n      if (scroller.scrollTop < lastTop - 1 && !atBottom) stick.current = false\n      if (atBottom) stick.current = true\n      lastTop = scroller.scrollTop\n      setIsAtBottom(stick.current || atBottom)\n    }\n    const follow = () => {\n      if (stick.current) {\n        scroller.scrollTop = scroller.scrollHeight\n        lastTop = scroller.scrollTop\n      } else onScroll()\n    }\n\n    follow()\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true })\n    const observer = new ResizeObserver(follow)\n    observer.observe(scroller)\n    if (content) observer.observe(content)\n    return () => {\n      scroller.removeEventListener(\"scroll\", onScroll)\n      observer.disconnect()\n    }\n  }, [scroller, content])\n\n  return { scrollRef: setScroller, contentRef: setContent, isAtBottom, scrollToBottom }\n}\n\nconst ChatContext = React.createContext<ChatScroll | null>(null)\n\n/** Read the scroll state of the surrounding <Chat>, e.g. to jump to the bottom after the user sends a message. */\nfunction useChatScroll() {\n  const ctx = React.useContext(ChatContext)\n  if (!ctx) throw new Error(\"useChatScroll must be used inside <Chat>.\")\n  return ctx\n}\n\ntype ChatProps = React.ComponentProps<\"div\">\n\n/** Full-height column: messages scroll, the footer stays put. Give it (or its parent) a height. */\nfunction Chat({ className, ...props }: ChatProps) {\n  const scroll = useStickToBottom()\n  return (\n    <ChatContext.Provider value={scroll}>\n      <div data-slot=\"chat\" className={cn(\"flex h-full min-h-0 w-full flex-col\", className)} {...props} />\n    </ChatContext.Provider>\n  )\n}\n\ntype ChatMessagesProps = React.ComponentProps<\"div\"> & {\n  /** Classes for the inner column that holds the messages (width, gap, padding). */\n  contentClassName?: string\n  /** Accessible name of the log. */\n  label?: string\n  /** Accessible name of the jump-to-latest button. */\n  scrollButtonLabel?: string\n}\n\n/**\n * The scrolling message log. Sticks to the bottom while content streams in, unless the\n * reader scrolled up; then shows a \"scroll to latest\" button.\n */\nfunction ChatMessages({\n  label,\n  scrollButtonLabel,\n  contentClassName,\n  className,\n  children,\n  ...props\n}: ChatMessagesProps) {\n  const msg = useMessages()\n  label ??= msg(\"ai-chat.label\", \"Conversation\")\n  scrollButtonLabel ??= msg(\"ai-chat.scrollButtonLabel\", \"Scroll to latest message\")\n  const ctx = React.useContext(ChatContext)\n  const local = useStickToBottom()\n  const { scrollRef, contentRef, isAtBottom, scrollToBottom } = ctx ?? local\n  const reduceMotion = useReducedMotion()\n\n  return (\n    <div data-slot=\"chat-messages-viewport\" className=\"relative min-h-0 flex-1\">\n      <div\n        ref={scrollRef}\n        data-slot=\"chat-messages\"\n        role=\"log\"\n        aria-live=\"polite\"\n        aria-label={label}\n        tabIndex={0}\n        className={cn(\n          \"absolute inset-0 overflow-y-auto overscroll-contain outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset\",\n          className\n        )}\n        {...props}\n      >\n        <div\n          ref={contentRef}\n          data-slot=\"chat-messages-content\"\n          className={cn(\"mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-6\", contentClassName)}\n        >\n          {children}\n        </div>\n      </div>\n      <AnimatePresence>\n        {!isAtBottom ? (\n          <motion.div\n            key=\"scroll-to-latest\"\n            className=\"absolute bottom-3 left-1/2 -translate-x-1/2\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }}\n            transition={reduceMotion ? { duration: 0.12 } : spring.snappy}\n          >\n            <Button\n              data-slot=\"chat-scroll-button\"\n              type=\"button\"\n              variant=\"outline\"\n              size=\"icon-sm\"\n              shape=\"pill\"\n              aria-label={scrollButtonLabel}\n              title={scrollButtonLabel}\n              onClick={() => scrollToBottom()}\n              className=\"bg-background shadow-[0_2px_8px_0_rgb(0_0_0/0.08)]\"\n            >\n              <ArrowDown />\n            </Button>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n\ntype ChatEmptyProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** Small mark above the title (an icon or logo). */\n  icon?: React.ReactNode\n  /** Headline, e.g. \"How can I help?\" */\n  title?: React.ReactNode\n  /** One line under the title. */\n  description?: React.ReactNode\n}\n\n/** Empty state shown before the first message. Put <ChatSuggestions> in children. */\nfunction ChatEmpty({ icon, title, description, className, children, ...props }: ChatEmptyProps) {\n  return (\n    <div\n      data-slot=\"chat-empty\"\n      className={cn(\"m-auto flex w-full max-w-md flex-col items-center gap-3 px-2 py-8 text-center\", className)}\n      {...props}\n    >\n      {icon ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"mb-1 flex size-10 items-center justify-center rounded-xl border bg-card text-muted-foreground [&_svg]:size-5\"\n        >\n          {icon}\n        </div>\n      ) : null}\n      {title ? <h2 className=\"text-base font-semibold tracking-tight text-balance\">{title}</h2> : null}\n      {description ? <p className=\"text-sm text-balance text-muted-foreground\">{description}</p> : null}\n      {children}\n    </div>\n  )\n}\n\nconst SuggestionsContext = React.createContext<((value: string) => void) | undefined>(undefined)\n\ntype ChatSuggestionsProps = Omit<React.ComponentProps<\"ul\">, \"onSelect\"> & {\n  /** Called with a suggestion's value when it is clicked. Usually sends it as a message. */\n  onSelect?: (value: string) => void\n}\n\nfunction ChatSuggestions({ onSelect, className, children, ...props }: ChatSuggestionsProps) {\n  const msg = useMessages()\n  return (\n    <SuggestionsContext.Provider value={onSelect}>\n      <ul\n        data-slot=\"chat-suggestions\"\n        aria-label={msg(\"ai-chat.suggestions\", \"Suggestions\")}\n        className={cn(\"mt-2 flex flex-wrap justify-center gap-2\", className)}\n        {...props}\n      >\n        {React.Children.map(children, (child) => (child == null ? null : <li className=\"max-w-full\">{child}</li>))}\n      </ul>\n    </SuggestionsContext.Provider>\n  )\n}\n\ntype ChatSuggestionProps = React.ComponentProps<\"button\"> & {\n  /** Text passed to onSelect. Defaults to the button's text when children is a string. */\n  value?: string\n}\n\nfunction ChatSuggestion({ value, className, children, onClick, ...props }: ChatSuggestionProps) {\n  const onSelect = React.useContext(SuggestionsContext)\n  return (\n    <button\n      data-slot=\"chat-suggestion\"\n      type=\"button\"\n      onClick={(event) => {\n        onClick?.(event)\n        if (event.defaultPrevented) return\n        const text = value ?? (typeof children === \"string\" ? children : \"\")\n        if (text) onSelect?.(text)\n      }}\n      className={cn(\n        \"inline-flex min-h-8 max-w-full items-center rounded-full border bg-card px-3 py-1 text-start text-[13px] text-foreground outline-none transition-colors duration-150 hover:border-foreground/20 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </button>\n  )\n}\n\ntype ChatFooterProps = React.ComponentProps<\"div\"> & {\n  /** Classes for the inner column (match ChatMessages' width). */\n  contentClassName?: string\n}\n\n/** Area under the log for the prompt input and a short disclaimer. */\nfunction ChatFooter({ contentClassName, className, children, ...props }: ChatFooterProps) {\n  return (\n    <div data-slot=\"chat-footer\" className={cn(\"sticky bottom-0 shrink-0 bg-background px-4 pt-2 pb-4\", className)} {...props}>\n      <div className={cn(\"mx-auto w-full max-w-3xl\", contentClassName)}>{children}</div>\n    </div>\n  )\n}\n\nexport {\n  Chat,\n  ChatMessages,\n  ChatEmpty,\n  ChatSuggestions,\n  ChatSuggestion,\n  ChatFooter,\n  useChatScroll,\n  type ChatProps,\n  type ChatMessagesProps,\n  type ChatEmptyProps,\n  type ChatSuggestionsProps,\n  type ChatSuggestionProps,\n  type ChatFooterProps,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/ai-chat.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "chat",
      "conversation",
      "llm",
      "scroll",
      "streaming",
      "layout"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "Layout only: <Chat> (a full-height column) > <ChatMessages> (role=log, auto-scroll) + <ChatFooter>. Put ai-message items inside ChatMessages and a prompt-input inside ChatFooter. It holds no chat state, so any source works (Vercel AI SDK useChat, your own fetch stream).",
      "whenToUse": [
        "Building an assistant, support bot or copilot panel from ai-message and prompt-input",
        "Any log that receives streaming content and should follow the newest line unless the reader scrolls up",
        "A first-run screen with suggested prompts (ChatEmpty + ChatSuggestions)"
      ],
      "whenNotToUse": [
        "A single one-off AI answer on a page (use ai-message or streaming-text alone)",
        "Terminal-style command output (use terminal)"
      ],
      "composesWith": [
        "ai-message",
        "prompt-input",
        "streaming-text",
        "tool-call-card",
        "reasoning-disclosure"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Focuses the message log (scroll with arrow keys / Page Up / Page Down), then the jump-to-latest button when shown"
        },
        {
          "keys": "Enter / Space",
          "action": "On a suggestion, sends it through onSelect"
        },
        {
          "keys": "—",
          "action": "The log is role=log with aria-live=polite; set aria-busy on ChatMessages while a reply streams to avoid announcing every token"
        }
      ],
      "customization": [
        "Give Chat (or its parent) a height; ChatMessages fills the remaining space and scrolls",
        "contentClassName on ChatMessages and ChatFooter sets the message column width (max-w-3xl by default)",
        "useChatScroll() inside Chat returns { isAtBottom, scrollToBottom } — call scrollToBottom() when the user sends a message",
        "Keep ChatMessages mounted and render ChatEmpty inside it when there are no messages",
        "AI SDK wiring: const { messages, sendMessage, status, stop } = useChat(); render messages in ChatMessages and <PromptInput status={status === 'streaming' || status === 'submitted' ? 'streaming' : 'idle'} onStop={stop} onSubmit={(text) => sendMessage({ text })} /> in ChatFooter"
      ]
    },
    "examples": [
      "ai-chat-demo",
      "ai-chat-empty"
    ],
    "url": "https://ui.ballmac.com/components/ai-chat"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}