You said, 6:10 PM
Hi! Can you help me with collections tomorrow?
AI Kit
Chat Thread
v1.0.0Added in 1.0.0
A React chat thread for AI chats: the scrolling conversation, pinned to the newest message while it streams, with day pills and follow-ups.
The thread opens at the newest message and follows a reply while it streams. Scroll up (wheel, finger, arrows, Page Up) and it lets go at once; a pill floats above the composer with the number of new replies, and tapping it glides back down and puts focus on the newest message. Sending always brings you to the bottom. Day pills stick to the top while their day scrolls under them. Reach the top of a long history and older messages load above without the view moving. Under the latest answer, follow-up rows send themselves on a tap or Enter (arrow keys move between them). An empty thread shows a greeting with starter cards that scroll sideways on phones. Tab reaches the header, the message list (it scrolls with the keyboard), each message's actions, the follow-ups, the jump pill and the composer. Screen readers hear once when a reply starts and when it is ready, never every streamed word.
Chat Thread is a Pro React component in the React AI components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add chat-thread, together with Chat Message, Suggestion Chips, Markdown, Button, Skeleton and 9 more and the npm packages motion and lucide-react.
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add chat-threadChat Thread is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Import ChatThread from @/components/ui/chat-thread.
import { useState } from "react"
import { ChatThread, type ChatMessageData, type ChatThreadLabels } from "@/components/ui/chat-thread"
import { PromptInput } from "@/components/ui/prompt-input"
import { createChatId, mockStream } from "@/lib/ai-chat"
const labels: Partial<ChatThreadLabels> = {
greeting: "Hi, Anna! How can I help today?",
jump: "Back to the latest message",
}
export function Assistant() {
const [messages, setMessages] = useState<ChatMessageData[]>([])
const [thinking, setThinking] = useState(false)
async function send(text: string) {
const id = createChatId("a")
setMessages((list) => [...list, { id: createChatId("u"), role: "user", content: text, createdAt: Date.now() }])
setThinking(true)
let content = ""
for await (const part of mockStream("I found **3 overdue invoices**, $22,775.95 in total.")) {
if (!content) setThinking(false)
content += part
const snapshot = content
// a new object only for the message that changed
setMessages((list) =>
list.some((m) => m.id === id)
? list.map((m) => (m.id === id ? { ...m, content: snapshot } : m))
: [...list, { id, role: "assistant", content: snapshot, status: "streaming", createdAt: Date.now() }],
)
}
setMessages((list) => list.map((m) => (m.id === id ? { ...m, status: "complete" } : m)))
}
return (
<div className="h-dvh">
<ChatThread
messages={messages}
thinking={thinking}
labels={labels}
composer={<PromptInput onSubmit={(message) => send(message.text)} />}
/>
</div>
)
}messages*ChatMessageData[]The conversation, oldest first. Keep message objects immutable: give only the message that changed a new object, so only it re-renders while text streams. Repeated ids are skipped.
thinkingdefault falsebooleanShows the pending assistant row (the mark and three dots) after sending and before the first token, unless the last message is already a pending or streaming answer.
composerReactNodeUsually a PromptInput. The thread pins it under the reading column above the safe area, with a fade over the messages behind it. Leave it out for a read-only thread.
headerReactNodeA bar above the scroller: the title, a model picker, share.
emptyStateReactNodeReplaces the greeting of an empty thread entirely.
greetingdefault ""ReactNodeThe empty state's title. Empty uses labels.greeting.
emptyDescriptiondefault ""ReactNodeA line under the greeting.
emptyIconReactNodeThe empty state's icon. An AI mark by default; a FluidOrb or a logo.
suggestionsChatSuggestion[]Starter cards in the empty state: a grid on desktop, a rail on phones.
followUpsChatSuggestion[]Rows under the latest answer, shown once it is complete and nothing is thinking.
onSuggestionSelect(suggestion: ChatSuggestion) => voidA starter card or a follow-up was chosen; send suggestion.prompt ?? suggestion.label.
onRegenerate((message: ChatMessageData) => void | Promise<unknown>)Regenerate on an assistant message. A returned promise keeps the button loading.
onEdit((message: ChatMessageData, content: string) => void | Promise<unknown>)A user message was edited in place and sent again.
onFeedbackChange((message: ChatMessageData, feedback: ChatFeedback) => void | Promise<unknown>)The thumbs on assistant messages. Store it in the message's feedback to control it; a rejected promise rolls back.
onRetry((message: ChatMessageData) => void | Promise<unknown>)Retry on a message with status error.
onCopy(message: ChatMessageData, text: string) => voidAfter a message was copied (copying is built in).
onBranchChange(message: ChatMessageData, index: number) => voidThe version switcher of a message with branch.count above 1.
messagePropsPartial<ChatMessageProps>Presentation props for every message: bubble, tone, color, showAvatar, assistantAvatar, timestamp, menu, labels, markdownProps. Keep it memoized (a module constant or useMemo) so finished messages never re-render.
renderMessage(message: ChatMessageData, props: ChatMessageProps) => ReactNodeFull control over a message (a custom card for some). Return undefined for the default ChatMessage.
variantdefault "default""default" | "bubbles" | "flat"The messages' layout. default: your bubble on the right, the answer as text on the left. bubbles: both in bubbles (support widgets). flat: both on the left with avatars and names (team threads). messageProps.variant wins.
sizedefault "md""sm" | "md"Message text, avatars and gaps. sm fits copilot side panels.
maxWidthdefault "md""sm" | "md" | "lg" | "full"The reading column of the messages and the composer: 640 / 768 / 960px or the full width.
dateSeparatorsdefault truebooleanA day pill between days ("Yesterday", "Today", "Monday", "Sep 22, 2026") that sticks to the top while its day scrolls. Messages without createdAt get none.
stickToBottomdefault truebooleanFollows new content while the reader is at the bottom, and lets go as soon as they scroll up.
jumpButtondefault truebooleanThe pill that shows while scrolled up, with the number of replies that arrived since.
hasMoredefault falsebooleanOlder messages exist: a sentinel at the top calls onLoadMore when it comes within 400px, once per cycle.
onLoadMore(() => void | Promise<unknown>)Prepend older messages. The first visible message stays exactly where it was.
loadingMoredefault falsebooleanA spinner at the top while older messages load.
loadingdefault falsebooleanThe first load: three skeleton message pairs in place of an empty thread.
announcedefault "notice""notice" | "full" | "off"The polite live region. notice: says when a reply starts and when it is ready. full: also reads the first 600 characters of the reply. The thread itself is never a live region.
nowDate | numberThe reference time for day labels and message times (default: the current minute).
localedefault ""stringDay labels, dates and counts. Empty uses the app's locale.
labelsPartial<ChatThreadLabels>Every string: conversation, messages, greeting, jump, newMessages(count, formatted), loadMore, loadingMore, loading, thinking, responseStarted, responseReady, responseFailed, today and yesterday (empty uses the locale's word), suggestions, followUps. English by default (DEFAULT_CHAT_THREAD_LABELS). Message strings go in messageProps.labels.
classNamesPartial<Record<"header" | "scroller" | "content" | "list" | "item" | "separator" | "loadMore" | "thinking" | "followUps" | "empty" | "jump" | "composer", string>>Extra classes for the inner parts. On a surface other than the page, set the composer fade with className="[--chat-thread-fade:var(--popover)]".
The day pill that sticks to the top of the scroller while its day scrolls under it, with the full date as an sr-only heading.
label*ReactNodeThe visible pill: "Today", "Yesterday", "Monday" or a date.
dateTimeInputThe day; its full date becomes the heading ("Thursday, September 24, 2026").
localestringIntl locale for the full date.
classNamesPartial<Record<"pill" | "heading", string>>Extra classes for the inner parts.
The greeting of a new conversation: an icon, a title, a line under it and starter cards.
iconReactNodeAn AI mark by default; a FluidOrb or your logo.
greetingdefault "How can I help?"ReactNodeThe title.
descriptionReactNodeThe line under the title.
suggestionsChatSuggestion[]Starter cards.
onSuggestionSelect(suggestion: ChatSuggestion) => voidRuns when a starter card is picked.
columnsSuggestionChipsColumnsCard grid columns from 640px; below that the cards scroll in a rail.
suggestionsLabelstringThe starter cards' group name.
The floating pill that brings the reader back to the newest message, with the count of messages that arrived meanwhile.
countnumberMessages that arrived while the reader was scrolled up; 0 shows the arrow alone.
localestringIntl locale for the count.
labelsPartial<Pick<ChatThreadLabels, "jump" | "newMessages">>The pill's text and its count sentence.
The English strings: conversation, messages, greeting ("How can I help?"), jump ("Jump to latest"), newMessages, the loading and response announcements and the suggestion group names. today and yesterday are empty, so the day pills use the locale's words. Type: ChatThreadLabels.
Conversation history, the thread, a model picker and usage in the composer.
A greeting over a FluidOrb with six starter cards.
A compact thread in a side panel next to a client page.
Both sides in bubbles, the person's in brand orange.
200 messages; older ones load at the top without the view jumping.
A 3,000-word answer with a counter of finished-message re-renders.
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
Run npx wingo-ui@latest add chat-thread. It also adds the Wingo UI items it builds on (Chat Message, Suggestion Chips, Markdown, Button, Skeleton and 9 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/chat-thread.
An empty thread shows a greeting with starter cards that scroll sideways on phones.
Building UI with AI agents11 min read
Building UI with AI agents10 min read
Overdue invoices in September
last action: — · announce: notice · 4 messages