Skip to content

Thread

Arrange a scrollable conversation.

Message 1: Keep related updates together.

Message 2: Keep related updates together.

Message 3: Keep related updates together.

Message 4: Keep related updates together.

Message 5: Keep related updates together.

Message 6: Keep related updates together.

Message 7: Keep related updates together.

Message 8: Keep related updates together.

Message 9: Keep related updates together.

Message 10: Keep related updates together.

Message 11: Keep related updates together.

Message 12: Keep related updates together.

View code
import { Thread } from "@stack/ui/thread";
import { Message } from "@stack/ui/message";
export default function Example() {
return (
<div className="h-80">
<Thread.Root defaultScrollPosition="start">
<Thread.Scroller>
<Thread.Messages>
<Thread.Content>
{Array.from({ length: 12 }, (_, index) => (
<Thread.Item key={index} messageId={`message-${index}`}>
<Message.Root>
<Message.Content>
<Message.Text
parts={[
{
type: "text",
text: `Message ${index + 1}: Keep related updates together.`,
},
]}
/>
</Message.Content>
</Message.Root>
</Thread.Item>
))}
</Thread.Content>
</Thread.Messages>
<Thread.ScrollButton />
</Thread.Scroller>
</Thread.Root>
</div>
);
}
import { Thread } from "@stack/ui/thread";
import { Message } from "@stack/ui/message";

Give the consumer-owned wrapper a height so the thread can scroll. Use stable message IDs and ScrollRestoration when switching conversations.

Scroll to an older message, switch conversations, then return. The host saves positions and supplies older pages; Thread.ScrollRestoration restores the saved anchor and reports when the conversation is ready. Grow the reply to check that reading an older message stays paused.

Restoring conversation

Message A-40
Message A-41
Message A-42
Message A-43
Message A-44
Message A-45
Message A-46
Message A-47
Message A-48
Message A-49
Message A-50
Message A-51
Message A-52
Message A-53
Message A-54
Message A-55
Message A-56
Message A-57
Message A-58
Message A-59

View code
import { useRef, useState, type RefObject } from "react";
import { Thread, type ThreadScrollPosition } from "@stack/ui/thread";
import { Button } from "@/components/ui/button";
function Conversation({
id,
positions,
}: {
id: string;
positions: RefObject<Map<string, ThreadScrollPosition>>;
}) {
const viewport = useRef<HTMLDivElement>(null);
const [position] = useState(() => positions.current.get(id));
const [first, setFirst] = useState(40);
const [ready, setReady] = useState(false);
const [reply, setReply] = useState("");
return (
<div className="h-96">
<Thread.Root>
<div className="flex flex-wrap items-center gap-2 py-3">
<Button
variant="outline"
disabled={first === 0}
onClick={() => setFirst((value) => Math.max(0, value - 20))}
>
Load older messages
</Button>
<Button
variant="outline"
onClick={() =>
setReply((value) => value + "More of the reply. ".repeat(20))
}
>
Grow reply
</Button>
<p role="status">
{ready ? `Conversation ${id}` : "Restoring conversation"}
</p>
</div>
<div className="min-h-0 flex-1">
<Thread.Scroller>
<Thread.Messages ref={viewport}>
<Thread.Content>
{Array.from(
{ length: 60 - first },
(_, offset) => first + offset,
).map((number) => (
<Thread.Item key={number} messageId={`${id}-${number}`}>
<div className="min-h-24 py-4">
Message {id}-{number}
{number === 59 && <p>{reply}</p>}
</div>
</Thread.Item>
))}
</Thread.Content>
</Thread.Messages>
<Thread.ScrollButton />
<Thread.ScrollRestoration
scopeKey={id}
viewportRef={viewport}
position={position}
onPositionChange={(value) => positions.current.set(id, value)}
onReady={() => setReady(true)}
cursor={first > 0 ? String(first) : null}
loadOlder={async (signal) => {
if (signal.aborted) return null;
setFirst((value) => Math.max(0, value - 20));
return {};
}}
/>
</Thread.Scroller>
</div>
</Thread.Root>
</div>
);
}
export default function Example() {
const positions = useRef(new Map<string, ThreadScrollPosition>());
const [conversation, setConversation] = useState("A");
return (
<div>
<nav className="flex gap-2" aria-label="Example conversations">
{["A", "B"].map((id) => (
<Button
key={id}
variant={conversation === id ? "default" : "outline"}
onClick={() => setConversation(id)}
>
Conversation {id}
</Button>
))}
</nav>
<Conversation
key={conversation}
id={conversation}
positions={positions}
/>
</div>
);
}

Tables list component-specific props and inherited control options. Components that forward native props also accept the applicable React HTML attributes and event handlers, including data-* and aria-* attributes.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.
autoScroll boolean No Follow the end of the conversation automatically. Default: true.
defaultScrollPosition MessageScrollerDefaultScrollPosition No Initial conversation scroll position. Default: "end".
scrollEdgeThreshold number No Distance from an edge used to detect whether it is reached.
scrollPreviousItemPeek number No Amount of the previous item visible at a scroll anchor.
scrollMargin number No Space around the scroll target.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.
preserveScrollOnPrepend boolean No Preserve the viewport when earlier content is prepended.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.
spacerClassName string No CSS classes for the scroll spacer.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.
messageId string No Stable message identifier.
scrollAnchor boolean No Use this message as a scroll anchor.

Also accepts native element props.

Prop Type Required Description
children ReactNode No Content rendered inside the component.
render (ReactElement<unknown, string | JSXElementConstructor<any>> | ((props: Record<string, unknown>, state: { active: boolean; direction: "start" | "end"; }) => ReactElement | null)) No Replace the underlying element using render composition.
behavior ScrollBehavior No Scroll behavior.
direction ("start" | "end") No Direction of the scroll action.
variant "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null No Appearance variant.
size "default" | "sm" | "xs" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null No Size variant.
Prop Type Required Description
scopeKey string Yes Identity of the current conversation or workspace.
viewportRef RefObject<HTMLElement | null> Yes Ref to the scrollable viewport.
position ThreadScrollPosition | null No Read once per scope. Subsequent saves must not restart restoration.
onPositionChange (position: ThreadScrollPosition) => void Yes Save the current conversation scroll position.
onReady (() => void) No Called when restoration has completed.
loading boolean No Whether data is loading. Default: false.
connectionState "connecting" | "connected" | "disconnected" No Current connection state used during scroll restoration. Default: "connected".
cursor string | null No Cursor identifying earlier available history. Default: null.
loadOlder ((signal: AbortSignal) => Promise<unknown | null>) No Update the host’s messages/cursor; return null if no page was loaded.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.

Also accepts native element props.

Prop Type Required Description
className string No Additional CSS classes for the underlying element.
children ReactNode No Content rendered inside the component.
dateTime string No Machine-readable timestamp for the time element.