Thread
Free · MITThe scrolling message column. It follows new content while you're at the bottom and stops following the moment you scroll up to read, offering a "Jump to latest" button instead. Pass empty for the first-run state and the composer as footer.
For long histories, virtualize renders only the messages in or near view (each child is one item, keyed by its key). The column stays a log, the message holding focus stays rendered, and Jump to latest works the same.
It opens at the bottom, or at anchor (the id of something in it, such as a chapter divider or the card that needs the reader), and then waits for the reader instead of following.
While the reader is scrolled up, "Jump to latest" pulses once each time a new message arrives, then stays still; the jump itself scrolls smoothly unless the OS asks for reduced motion or data-motion is "none".
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/thread
Brings in: locale, use-virtual-list, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { Composer } from "@/components/composer"
import { Message } from "@/components/message"
import { Thread } from "@/components/thread"
import type { Message as MessageData } from "@/lib/chat"
const agent = { id: "a1", name: "Support agent", role: "assistant" }
const line = (i: number): MessageData => ({
id: `h${i}`,
author: i % 2 ? { role: "agent", agent } : { role: "user" },
status: "complete",
blocks: [{ kind: "paragraph", nodes: [{ kind: "text", text: i % 2 ? `Order ${1000 + i} shipped on Tuesday.` : `Where is order ${1001 + i}?` }] }],
})
const history = Array.from({ length: 600 }, (_, i) => line(i))
export default function Example() {
const [value, setValue] = React.useState("")
const [messages, setMessages] = React.useState<MessageData[]>([])
const [long, setLong] = React.useState(false)
const shown = long ? [...history, ...messages] : messages
const send = (text: string) => {
setMessages((m) => [
...m,
{ id: `u${m.length}`, author: { role: "user" }, status: "complete", blocks: [{ kind: "paragraph", nodes: [{ kind: "text", text }] }] },
{ id: `a${m.length}`, author: { role: "agent", agent }, status: "complete", blocks: [{ kind: "paragraph", nodes: [{ kind: "text", text: "Got it. I’ll look into that." }] }] },
])
setValue("")
}
return (
<div className="flex flex-col gap-2">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" checked={long} onChange={(e) => setLong(e.target.checked)} />
A history of 600 messages, virtualised
</label>
<div className="h-80 overflow-hidden rounded-lg border">
<Thread
className="h-full"
// virtualize renders only the messages in or near view; each child needs a key.
virtualize={long}
empty={<p className="text-sm text-muted-foreground">Ask about an order to start.</p>}
footer={<Composer value={value} onChange={setValue} onSubmit={send} placeholder="Ask about an order" />}
>
{shown.length ? shown.map((m) => <Message key={m.id} message={m} />) : null}
</Thread>
</div>
</div>
)
}<Thread>
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | undefined | — | What goes inside. |
| empty | React.ReactNode | undefined | — | Shown before the first message. |
| footer | React.ReactNode | undefined | — | Below the messages, usually the composer. |
| className | string | undefined | — | Extra classes for the outer element. |
| contentClassName | string | undefined | — | Classes for the message column. |
| virtualize | boolean | undefined | false | Render only the messages in or near view, for histories of hundreds of messages. Give each child a `key`. Defaults to false. |
| anchor | string | undefined | — | The `id` of an element in the thread to open at, instead of the bottom: a chapter divider, or the card that needs the reader. Read once, when the messages first appear; if it isn't among them, the thread opens at the bottom. Ignored with `virtualize`, where it may not be rendered yet. |
| scrollerRef | React.Ref<HTMLDivElement> | undefined | — | The scrolling element, for your own scroll handling. |
| onDrop | ((event: React.DragEvent<HTMLDivElement>) => void) | undefined | — | Files or other data dropped on the thread. Dragging over it is allowed only when this is given. |
| fade | boolean | undefined | true | The soft fade at the bottom of the column. Defaults to true. |
Strings
The fixed words, in English by default. Change any of them under thread in <LocaleProvider strings={{ … }}>; the table is exported as threadStrings.
| Key | English |
|---|---|
| messages | Messages |
| jumpToLatest | Jump to latest |