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.
"use client"
import * as React from "react"
import { Chat, ChatFooter, ChatMessages } from "@/components/ballmac/ai-chat"
import { Message, MessageAvatar, MessageContent } from "@/components/ballmac/ai-message"
import { PromptInput, PromptInputSubmit, PromptInputTextarea, PromptInputToolbar } from "@/components/ballmac/prompt-input"
import { StreamingText } from "@/components/ballmac/streaming-text"
type Turn = { id: number; role: "user" | "assistant"; text: string; streaming?: boolean }
const REPLY =
"Ballmac components install into components/ballmac, so they never overwrite your shadcn/ui files. Add one with `npx shadcn@latest add @ballmac/prompt-input`, then import it from @/components/ballmac/prompt-input."
export default function AiChatDemo() {
const [turns, setTurns] = React.useState<Turn[]>([
{ id: 1, role: "user", text: "Where do Ballmac components get installed?" },
{ id: 2, role: "assistant", text: REPLY },
])
const [streaming, setStreaming] = React.useState(false)
const timer = React.useRef<ReturnType<typeof setInterval> | null>(null)
React.useEffect(() => () => {
if (timer.current) clearInterval(timer.current)
}, [])
const stop = () => {
if (timer.current) clearInterval(timer.current)
setStreaming(false)
setTurns((t) => t.map((x) => ({ ...x, streaming: false })))
}
const send = (value: string) => {
const id = Date.now()
setTurns((t) => [...t, { id, role: "user", text: value }, { id: id + 1, role: "assistant", text: "", streaming: true }])
setStreaming(true)
let i = 0
timer.current = setInterval(() => {
i += 4
setTurns((t) => t.map((x) => (x.id === id + 1 ? { ...x, text: REPLY.slice(0, i) } : x)))
if (i >= REPLY.length) stop()
}, 30)
}
return (
<div className="h-[420px] w-full max-w-2xl overflow-hidden rounded-xl border bg-background">
<Chat>
<ChatMessages contentClassName="px-4 py-5">
{turns.map((t) => (
<Message key={t.id} role={t.role}>
<MessageAvatar>{t.role === "user" ? "YO" : "AI"}</MessageAvatar>
<MessageContent>{t.role === "assistant" ? <StreamingText text={t.text} streaming={t.streaming} /> : t.text}</MessageContent>
</Message>
))}
</ChatMessages>
<ChatFooter contentClassName="px-4 pb-4">
<PromptInput onSubmit={send} status={streaming ? "streaming" : "idle"} onStop={stop}>
<PromptInputTextarea placeholder="Ask about Ballmac UI…" />
<PromptInputToolbar>
<PromptInputSubmit className="ms-auto" />
</PromptInputToolbar>
</PromptInput>
</ChatFooter>
</Chat>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/ai-chatInstall the dependencies.
$ pnpm add lucide-react motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/ai-chat.tsx// Ballmac UI: AI Chat. https://ui.ballmac.com/components/ai-chat "use client" import * as React from "react" import { ArrowDown } from "lucide-react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { Button } from "@/components/ballmac/button" import { spring } from "@/lib/ballmac/motion" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" /** Pixels from the bottom that still count as "at the bottom". */ const BOTTOM_THRESHOLD = 48 type ChatScroll = { /** Callback ref for the scroll container. */ scrollRef: (node: HTMLDivElement | null) => void /** Callback ref for the element whose height grows as content streams in. */ contentRef: (node: HTMLDivElement | null) => void /** Whether the view is pinned to the newest content. */ isAtBottom: boolean /** Scroll to the newest content and stick there while it grows. */ scrollToBottom: (behavior?: ScrollBehavior) => void } function useStickToBottom(): ChatScroll { const [scroller, setScroller] = React.useState<HTMLDivElement | null>(null) const [content, setContent] = React.useState<HTMLDivElement | null>(null) const stick = React.useRef(true) const [isAtBottom, setIsAtBottom] = React.useState(true) const reduceMotion = useReducedMotion() const scrollToBottom = React.useCallback( (behavior: ScrollBehavior = reduceMotion ? "auto" : "smooth") => { stick.current = true setIsAtBottom(true) scroller?.scrollTo({ top: scroller.scrollHeight, behavior }) }, [reduceMotion, scroller] ) React.useEffect(() => { if (!scroller) return let lastTop = scroller.scrollTop const onScroll = () => { const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight const atBottom = distance <= BOTTOM_THRESHOLD // Only an upward scroll releases the pin; smooth scrolls towards the bottom keep it. if (scroller.scrollTop < lastTop - 1 && !atBottom) stick.current = false if (atBottom) stick.current = true lastTop = scroller.scrollTop setIsAtBottom(stick.current || atBottom) } const follow = () => { if (stick.current) { scroller.scrollTop = scroller.scrollHeight lastTop = scroller.scrollTop } else onScroll() } follow() scroller.addEventListener("scroll", onScroll, { passive: true }) const observer = new ResizeObserver(follow) observer.observe(scroller) if (content) observer.observe(content) return () => { scroller.removeEventListener("scroll", onScroll) observer.disconnect() } }, [scroller, content]) return { scrollRef: setScroller, contentRef: setContent, isAtBottom, scrollToBottom } } const ChatContext = React.createContext<ChatScroll | null>(null) /** Read the scroll state of the surrounding <Chat>, e.g. to jump to the bottom after the user sends a message. */ function useChatScroll() { const ctx = React.useContext(ChatContext) if (!ctx) throw new Error("useChatScroll must be used inside <Chat>.") return ctx } type ChatProps = React.ComponentProps<"div"> /** Full-height column: messages scroll, the footer stays put. Give it (or its parent) a height. */ function Chat({ className, ...props }: ChatProps) { const scroll = useStickToBottom() return ( <ChatContext.Provider value={scroll}> <div data-slot="chat" className={cn("flex h-full min-h-0 w-full flex-col", className)} {...props} /> </ChatContext.Provider> ) } type ChatMessagesProps = React.ComponentProps<"div"> & { /** Classes for the inner column that holds the messages (width, gap, padding). */ contentClassName?: string /** Accessible name of the log. */ label?: string /** Accessible name of the jump-to-latest button. */ scrollButtonLabel?: string } /** * The scrolling message log. Sticks to the bottom while content streams in, unless the * reader scrolled up; then shows a "scroll to latest" button. */ function ChatMessages({ label, scrollButtonLabel, contentClassName, className, children, ...props }: ChatMessagesProps) { const msg = useMessages() label ??= msg("ai-chat.label", "Conversation") scrollButtonLabel ??= msg("ai-chat.scrollButtonLabel", "Scroll to latest message") const ctx = React.useContext(ChatContext) const local = useStickToBottom() const { scrollRef, contentRef, isAtBottom, scrollToBottom } = ctx ?? local const reduceMotion = useReducedMotion() return ( <div data-slot="chat-messages-viewport" className="relative min-h-0 flex-1"> <div ref={scrollRef} data-slot="chat-messages" role="log" aria-live="polite" aria-label={label} tabIndex={0} className={cn( "absolute inset-0 overflow-y-auto overscroll-contain outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset", className )} {...props} > <div ref={contentRef} data-slot="chat-messages-content" className={cn("mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-6", contentClassName)} > {children} </div> </div> <AnimatePresence> {!isAtBottom ? ( <motion.div key="scroll-to-latest" className="absolute bottom-3 left-1/2 -translate-x-1/2" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.96 }} transition={reduceMotion ? { duration: 0.12 } : spring.snappy} > <Button data-slot="chat-scroll-button" type="button" variant="outline" size="icon-sm" shape="pill" aria-label={scrollButtonLabel} title={scrollButtonLabel} onClick={() => scrollToBottom()} className="bg-background shadow-[0_2px_8px_0_rgb(0_0_0/0.08)]" > <ArrowDown /> </Button> </motion.div> ) : null} </AnimatePresence> </div> ) } type ChatEmptyProps = Omit<React.ComponentProps<"div">, "title"> & { /** Small mark above the title (an icon or logo). */ icon?: React.ReactNode /** Headline, e.g. "How can I help?" */ title?: React.ReactNode /** One line under the title. */ description?: React.ReactNode } /** Empty state shown before the first message. Put <ChatSuggestions> in children. */ function ChatEmpty({ icon, title, description, className, children, ...props }: ChatEmptyProps) { return ( <div data-slot="chat-empty" className={cn("m-auto flex w-full max-w-md flex-col items-center gap-3 px-2 py-8 text-center", className)} {...props} > {icon ? ( <div aria-hidden="true" className="mb-1 flex size-10 items-center justify-center rounded-xl border bg-card text-muted-foreground [&_svg]:size-5" > {icon} </div> ) : null} {title ? <h2 className="text-base font-semibold tracking-tight text-balance">{title}</h2> : null} {description ? <p className="text-sm text-balance text-muted-foreground">{description}</p> : null} {children} </div> ) } const SuggestionsContext = React.createContext<((value: string) => void) | undefined>(undefined) type ChatSuggestionsProps = Omit<React.ComponentProps<"ul">, "onSelect"> & { /** Called with a suggestion's value when it is clicked. Usually sends it as a message. */ onSelect?: (value: string) => void } function ChatSuggestions({ onSelect, className, children, ...props }: ChatSuggestionsProps) { const msg = useMessages() return ( <SuggestionsContext.Provider value={onSelect}> <ul data-slot="chat-suggestions" aria-label={msg("ai-chat.suggestions", "Suggestions")} className={cn("mt-2 flex flex-wrap justify-center gap-2", className)} {...props} > {React.Children.map(children, (child) => (child == null ? null : <li className="max-w-full">{child}</li>))} </ul> </SuggestionsContext.Provider> ) } type ChatSuggestionProps = React.ComponentProps<"button"> & { /** Text passed to onSelect. Defaults to the button's text when children is a string. */ value?: string } function ChatSuggestion({ value, className, children, onClick, ...props }: ChatSuggestionProps) { const onSelect = React.useContext(SuggestionsContext) return ( <button data-slot="chat-suggestion" type="button" onClick={(event) => { onClick?.(event) if (event.defaultPrevented) return const text = value ?? (typeof children === "string" ? children : "") if (text) onSelect?.(text) }} className={cn( "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", className )} {...props} > {children} </button> ) } type ChatFooterProps = React.ComponentProps<"div"> & { /** Classes for the inner column (match ChatMessages' width). */ contentClassName?: string } /** Area under the log for the prompt input and a short disclaimer. */ function ChatFooter({ contentClassName, className, children, ...props }: ChatFooterProps) { return ( <div data-slot="chat-footer" className={cn("sticky bottom-0 shrink-0 bg-background px-4 pt-2 pb-4", className)} {...props}> <div className={cn("mx-auto w-full max-w-3xl", contentClassName)}>{children}</div> </div> ) } export { Chat, ChatMessages, ChatEmpty, ChatSuggestions, ChatSuggestion, ChatFooter, useChatScroll, type ChatProps, type ChatMessagesProps, type ChatEmptyProps, type ChatSuggestionsProps, type ChatSuggestionProps, type ChatFooterProps, }Update the import paths to match your project setup.
Usage
import { Chat, ChatMessages, ChatEmpty, ChatSuggestions, ChatSuggestion, ChatFooter, useChatScroll } from "@/components/ballmac/ai-chat"The full example is in the Code tab above.
Examples
Empty state with suggestions
How can I help?
Ask about your code, docs or data.
"use client"
import { Sparkles } from "lucide-react"
import { Chat, ChatEmpty, ChatSuggestion, ChatSuggestions } from "@/components/ballmac/ai-chat"
export default function AiChatEmpty() {
return (
<div className="h-[360px] w-full max-w-2xl overflow-hidden rounded-xl border bg-background">
<Chat>
<ChatEmpty icon={<Sparkles />} title="How can I help?" description="Ask about your code, docs or data.">
<ChatSuggestions onSelect={() => {}}>
<ChatSuggestion>Summarize this pull request</ChatSuggestion>
<ChatSuggestion>Write tests for the checkout flow</ChatSuggestion>
<ChatSuggestion>Explain this error</ChatSuggestion>
</ChatSuggestions>
</ChatEmpty>
</Chat>
</div>
)
}API reference
<ChatMessages>
| Prop | Type | Default |
|---|---|---|
contentClassNameClasses for the inner column that holds the messages (width, gap, padding). | string | — |
labelAccessible name of the log. | string | — |
scrollButtonLabelAccessible name of the jump-to-latest button. | string | — |
<ChatEmpty>
| Prop | Type | Default |
|---|---|---|
iconSmall mark above the title (an icon or logo). | React.ReactNode | — |
titleHeadline, e.g. "How can I help?" | React.ReactNode | — |
descriptionOne line under the title. | React.ReactNode | — |
<ChatSuggestions>
| Prop | Type | Default |
|---|---|---|
onSelectCalled with a suggestion's value when it is clicked. Usually sends it as a message. | (value: string) => void | — |
<ChatSuggestion>
| Prop | Type | Default |
|---|---|---|
valueText passed to onSelect. Defaults to the button's text when children is a string. | string | — |
<ChatFooter>
| Prop | Type | Default |
|---|---|---|
contentClassNameClasses for the inner column (match ChatMessages' width). | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the message log (scroll with arrow keys / Page Up / Page Down), then the jump-to-latest button when shown |
| Enter / Space | On a suggestion, sends it through onSelect |
| — | The log is role=log with aria-live=polite; set aria-busy on ChatMessages while a reply streams to avoid announcing every token |
Use with AI
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). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI AI Chat (@ballmac/ai-chat) to this project with the shadcn MCP, then use it where it fits.
Use it for
- 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)
Not for
- A single one-off AI answer on a page (use ai-message or streaming-text alone)
- Terminal-style command output (use terminal)
Registry JSON: https://ui.ballmac.com/r/ai-chat.json
Credits
Free to use in personal and commercial projects.
Pairs well with
AI Message
A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
Prompt Input
An auto-growing chat input: Enter sends, Shift+Enter adds a line (IME-safe), the send button turns into Stop while streaming, with file attachment chips and a toolbar slot.
Streaming Text
Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.