# Chat Thread

> A React chat thread for AI chats: the scrolling conversation, pinned to the newest message while it streams, with day pills and follow-ups.

- Type: React component (AI Kit)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add chat-thread`
- Docs: https://wingo-ui.com/components/chat-thread

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add chat-thread
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/chat-thread` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/chat-thread.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Chat Message](https://wingo-ui.com/components/chat-message) ([Markdown](https://wingo-ui.com/components/chat-message.md))
- [Suggestion Chips](https://wingo-ui.com/components/suggestion-chips) ([Markdown](https://wingo-ui.com/components/suggestion-chips.md))
- [Markdown](https://wingo-ui.com/components/markdown) ([Markdown](https://wingo-ui.com/components/markdown.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [useStickToBottom](https://wingo-ui.com/components/use-stick-to-bottom) ([Markdown](https://wingo-ui.com/components/use-stick-to-bottom.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [AI Chat](https://wingo-ui.com/components/ai-chat) ([Markdown](https://wingo-ui.com/components/ai-chat.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
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>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |
| `thinking?` | `boolean` | `false` | Shows 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. |
| `composer?` | `ReactNode` |  | Usually 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. |
| `header?` | `ReactNode` |  | A bar above the scroller: the title, a model picker, share. |
| `emptyState?` | `ReactNode` |  | Replaces the greeting of an empty thread entirely. |
| `greeting?` | `ReactNode` | `""` | The empty state's title. Empty uses labels.greeting. |
| `emptyDescription?` | `ReactNode` | `""` | A line under the greeting. |
| `emptyIcon?` | `ReactNode` |  | The empty state's icon. An AI mark by default; a FluidOrb or a logo. |
| `suggestions?` | `ChatSuggestion[]` |  | Starter cards in the empty state: a grid on desktop, a rail on phones. |
| `followUps?` | `ChatSuggestion[]` |  | Rows under the latest answer, shown once it is complete and nothing is thinking. |
| `onSuggestionSelect?` | `(suggestion: ChatSuggestion) => void` |  | A 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) => void` |  | After a message was copied (copying is built in). |
| `onBranchChange?` | `(message: ChatMessageData, index: number) => void` |  | The version switcher of a message with branch.count above 1. |
| `messageProps?` | `Partial<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) => ReactNode` |  | Full control over a message (a custom card for some). Return undefined for the default ChatMessage. |
| `variant?` | `"default" \| "bubbles" \| "flat"` | `"default"` | 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. |
| `size?` | `"sm" \| "md"` | `"md"` | Message text, avatars and gaps. sm fits copilot side panels. |
| `maxWidth?` | `"sm" \| "md" \| "lg" \| "full"` | `"md"` | The reading column of the messages and the composer: 640 / 768 / 960px or the full width. |
| `dateSeparators?` | `boolean` | `true` | A day pill between days ("Yesterday", "Today", "Monday", "Sep 22, 2026") that sticks to the top while its day scrolls. Messages without createdAt get none. |
| `stickToBottom?` | `boolean` | `true` | Follows new content while the reader is at the bottom, and lets go as soon as they scroll up. |
| `jumpButton?` | `boolean` | `true` | The pill that shows while scrolled up, with the number of replies that arrived since. |
| `hasMore?` | `boolean` | `false` | Older 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. |
| `loadingMore?` | `boolean` | `false` | A spinner at the top while older messages load. |
| `loading?` | `boolean` | `false` | The first load: three skeleton message pairs in place of an empty thread. |
| `announce?` | `"notice" \| "full" \| "off"` | `"notice"` | 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. |
| `now?` | `Date \| number` |  | The reference time for day labels and message times (default: the current minute). |
| `locale?` | `string` | `""` | Day labels, dates and counts. Empty uses the app's locale. |
| `labels?` | `Partial<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. |
| `classNames?` | `Partial<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)]". |

## Parts

### ChatThreadSeparator (component)

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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` |  | The visible pill: "Today", "Yesterday", "Monday" or a date. |
| `date?` | `TimeInput` |  | The day; its full date becomes the heading ("Thursday, September 24, 2026"). |
| `locale?` | `string` |  | Intl locale for the full date. |
| `classNames?` | `Partial<Record<"pill" \| "heading", string>>` |  | Extra classes for the inner parts. |

### ChatThreadEmpty (component)

The greeting of a new conversation: an icon, a title, a line under it and starter cards.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon?` | `ReactNode` |  | An AI mark by default; a FluidOrb or your logo. |
| `greeting?` | `ReactNode` | `"How can I help?"` | The title. |
| `description?` | `ReactNode` |  | The line under the title. |
| `suggestions?` | `ChatSuggestion[]` |  | Starter cards. |
| `onSuggestionSelect?` | `(suggestion: ChatSuggestion) => void` |  | Runs when a starter card is picked. |
| `columns?` | `SuggestionChipsColumns` |  | Card grid columns from 640px; below that the cards scroll in a rail. |
| `suggestionsLabel?` | `string` |  | The starter cards' group name. |

### ChatThreadJump (component)

The floating pill that brings the reader back to the newest message, with the count of messages that arrived meanwhile.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `count?` | `number` |  | Messages that arrived while the reader was scrolled up; 0 shows the arrow alone. |
| `locale?` | `string` |  | Intl locale for the count. |
| `labels?` | `Partial<Pick<ChatThreadLabels, "jump" \| "newMessages">>` |  | The pill's text and its count sentence. |

### DEFAULT_CHAT_THREAD_LABELS (constant)

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.

## Examples

- **Full app**: Conversation history, the thread, a model picker and usage in the composer.
- **Empty state**: A greeting over a FluidOrb with six starter cards.
- **Copilot panel**: A compact thread in a side panel next to a client page.
- **Support widget**: Both sides in bubbles, the person's in brand orange.
- **Long history**: 200 messages; older ones load at the top without the view jumping.
- **Streaming stress**: A 3,000-word answer with a counter of finished-message re-renders.

## See also

- [Chat Message](https://wingo-ui.com/components/chat-message) ([Markdown](https://wingo-ui.com/components/chat-message.md))
- [Suggestion Chips](https://wingo-ui.com/components/suggestion-chips) ([Markdown](https://wingo-ui.com/components/suggestion-chips.md))
- [Prompt Input](https://wingo-ui.com/components/prompt-input) ([Markdown](https://wingo-ui.com/components/prompt-input.md))
- [Conversation List](https://wingo-ui.com/components/conversation-list) ([Markdown](https://wingo-ui.com/components/conversation-list.md))
- [useStickToBottom](https://wingo-ui.com/components/use-stick-to-bottom) ([Markdown](https://wingo-ui.com/components/use-stick-to-bottom.md))
- [AI Chat](https://wingo-ui.com/components/ai-chat) ([Markdown](https://wingo-ui.com/components/ai-chat.md))

---

Canonical: https://wingo-ui.com/components/chat-thread
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
